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:
AddRemoveInsertRemoveAtClearSortReverseAddRange- 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.
FootballTeamis not really a kind ofList<FootballPlayer>FootballTeamis 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:
InsertItemRemoveItemSetItemClearItems
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.
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
FootballTeamobject is created. - Inside it,
_playersstarts as an emptyList<FootballPlayer>. TeamNamebecomes"Falcons".
2. Add the first player
team.AddPlayer(new FootballPlayer("Alex", 10));
- A
FootballPlayerrecord is created with nameAlexand jersey number10. AddPlayerchecks whether any existing player has jersey number10.- 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.
AddPlayerchecks the current list.- No one has jersey number
7, so the player is added.
4. Access players
var count = team.Players.Count;
Playersexposes anIReadOnlyList<FootballPlayer>.- Callers can inspect the roster.
- Callers cannot directly call methods like
Addon 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 uniquenessDictionary<TKey, TValue>for fast lookup- a database-backed repository
- a sorted structure
If your public API promised List<T> 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.
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:
PlayerCollectionEmailAddressListValidationErrorCollection
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
| 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> | Good for reusable custom collections |
Implement IList<T> manually | Full custom list behavior | Maximum control | More boilerplate | Only when you truly need a custom list implementation |
Expose IReadOnlyList<T> | Read-only view for callers | Safe, simple, common in APIs | Callers cannot mutate directly | Great for encapsulated models |
List<T> vs Collection<T>
| Feature | List<T> | Collection<T> |
|---|---|---|
| Main purpose | Fast general-purpose list | Base class for custom collections |
| Rich convenience methods | Yes | Fewer |
| Designed for inheritance | Not really | Yes |
| Overridable mutation hooks | No meaningful full set for customization | Yes |
| Good for domain entities | Usually no | Only 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.
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
InsertItemRemoveItemSetItemClearItems
Common interfaces
IEnumerable<T>: can be iteratedICollection<T>: basic collection operationsIList<T>: index-based mutable listIReadOnlyList<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.
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.
Best Way to Repeat a Character in C#: Building Repeated Strings Efficiently
Learn the best way to repeat a character in C#, compare StringBuilder, string concatenation, and simpler built-in options.
C# Access Modifiers and static: public, private, protected, and Defaults
Learn how C# public, private, protected, and default access control visibility, and how static differs from instance members.