20 - Generics
Generics - <T>
- Generics introduce to the .NET Framework the concept of type parameters, which make it possible to design classes and methods that defer the specification of one or more types until the class or method is declared and instantiated by client code.
- Generics are also known as parameterized types or parametric polymorphism.
- Generics are the foundation of the repository pattern, Unit of Work, and DI registration used throughout this course.
// Declare the generic class.
public class GenericList<T> {
public void Add(T input) {
// Do nothing
}
}
class TestGenericList {
private class ExampleClass { }
static void Main() {
// Declare a list of type int.
var list1 = new GenericList<int>();
list1.Add(1);
// Declare a list of type string.
var list2 = new GenericList<string>();
list2.Add("");
// Declare a list of type ExampleClass.
var list3 = new GenericList<ExampleClass>();
list3.Add(new ExampleClass());
}
}
Generic Methods
- A method can have its own type parameter(s), independent of the enclosing class
- The compiler can often infer the type argument from the method arguments
public class Helpers {
// Generic method in a non-generic class
public T FindById<T>(List<T> items, Func<T, bool> predicate) {
return items.First(predicate);
}
}
// Usage -- type is inferred from the argument
var people = new List<Person>();
var person = helpers.FindById(people, p => p.Id == id);
// Explicit type argument -- same result
var person = helpers.FindById<Person>(people, p => p.Id == id);
- You will see generic methods everywhere:
DbContext.Set<TEntity>(),mapper.Map<PersonDto>(person),services.AddScoped<IRepo, EFRepo>()
Generic Interfaces
- Interfaces can be generic -- the implementing class specifies the concrete type
- The generic interface becomes a contract. The consumer codes against
IRepository<Person>, notPersonRepository.
public interface IRepository<TEntity> {
TEntity? Find(int id);
IEnumerable<TEntity> All();
void Add(TEntity entity);
void Remove(TEntity entity);
}
// Concrete implementation -- closes the type parameter to Person
public class PersonRepository : IRepository<Person> {
public Person? Find(int id) { /* ... */ }
public IEnumerable<Person> All() { /* ... */ }
public void Add(Person entity) { /* ... */ }
public void Remove(Person entity) { /* ... */ }
}
Generic Classes
- A generic class that implements a generic interface -- the pattern used in repositories
public class EFRepository<TEntity> : IRepository<TEntity> {
protected DbSet<TEntity> DbSet;
public TEntity? Find(int id) => DbSet.Find(id);
public IEnumerable<TEntity> All() => DbSet.ToList();
public void Add(TEntity entity) => DbSet.Add(entity);
public void Remove(TEntity entity) => DbSet.Remove(entity);
}
- Write the CRUD logic once. It works for Person, ContactType, Order -- any entity type.
- This is DRY at the architectural level -- one generic class replaces N concrete repository classes.
Generic Inheritance
Three inheritance patterns with generic types:
1. Closing the type parameter -- Person is now fixed
public class PersonRepo : EFRepository<Person> { }
2. Passing through the type parameter -- still generic
public class CachedRepository<T> : IRepository<T> { /* adds caching layer */ }
3. Extending a closed generic interface -- add custom methods
public interface IPersonRepository : IRepository<Person> {
IEnumerable<Person> FindByLastName(string lastName);
}
// Inherits from generic base + implements specific interface
public class PersonEFRepository : EFRepository<Person>, IPersonRepository {
public IEnumerable<Person> FindByLastName(string lastName) { /* ... */ }
}
- Custom repo methods go in
IPersonRepository. Generic CRUD stays inIRepository<TEntity>. Best of both worlds.
Multiple Type Parameters
- A generic type can have multiple type parameters
// Built-in examples
Dictionary<string, List<Person>> cache;
Func<Person, bool> predicate;
KeyValuePair<Guid, string> pair;
// Method with multiple type parameters
public TDest Map<TSource, TDest>(TSource source) { /* ... */ }
var dto = mapper.Map<Person, PersonDto>(person);
- You will encounter
Expression<Func<TEntity, object>>in repository methods -- read it as "a function that takesTEntityand returns something". The details come later.
Generics - Constraints 1
- Constraints restrict which types can be used as type arguments. The compiler enforces them at compile time.
where T: struct- The type argument must be a non-nullable value type. Implies new().
where T: class- The type argument must be a reference type. This constraint applies also to any class, interface, delegate, or array type. Must be non-nullable.
where T: class?- Reference type, can be nullable.
where T: notnull- T must be a non-nullable type
where T: unmanaged- The type argument must not be a reference type and must not contain any reference type members at any level of nesting.
where T: new()- The type argument must have a public parameterless constructor. When used together with other constraints, the new() constraint must be specified last.
where T: <base class name>- The type argument must be or derive from the specified base class. T must be a non-nullable reference type derived from the specified base class.
where T: <base class name>?- The type argument must be or derive from the specified base class. T may be either a nullable or non-nullable type derived from the specified base class.
where T: <interface name>- The type argument must be or implement the specified interface. Multiple interface constraints can be specified. The constraining interface can also be generic. T must be a non-nullable type that implements the specified interface.
where T: <interface name>?- The type argument must be or implement the specified interface. Multiple interface constraints can be specified. The constraining interface can also be generic. T may be a nullable reference type, a non-nullable reference type, or a value type. T may not be a nullable value type.
where T: U- The type argument supplied for T must be or derive from the argument supplied for U. In a nullable context, if U is a non-nullable reference type, T must be non-nullable reference type. If U is a nullable reference type, T may be either nullable or non-nullable.
Generics - Constraints 2
Some of the constraints are mutually exclusive.
- All value types must have an accessible parameterless constructor.
- The struct constraint implies the new() constraint and the new() constraint cannot be combined with the struct constraint.
- The unmanaged constraint implies the struct constraint.
- The unmanaged constraint cannot be combined with either the struct or new() constraints.
Unmanaged type
- sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, or bool
- Enum, pointer, struct that contains only fields of unmanaged types
Constraints in Practice
- The most common constraint in this course:
where TEntity : BaseEntity
public abstract class BaseEntity {
public Guid Id { get; set; }
}
public class Person : BaseEntity {
public string FirstName { get; set; } = default!;
public string LastName { get; set; } = default!;
}
- The constraint lets the compiler know what
TEntitycan do:
public class EFRepository<TEntity> where TEntity : BaseEntity {
public TEntity? FindById(Guid id) =>
DbSet.FirstOrDefault(e => e.Id == id);
// ^^^^
// Without the constraint, the compiler wouldn't know TEntity has an Id property
}
- Without a constraint,
Tis justobject-- you can only callToString(),Equals(),GetHashCode() - Same pattern appears in UoW:
EFBaseUOW<TDbContext> where TDbContext : DbContext
Covariance and Contravariance
out T= covariance -- the type only comes out (return values)in T= contravariance -- the type only goes in (parameters)
// IEnumerable<out T> is covariant
IEnumerable<Person> people = new List<Person>();
IEnumerable<BaseEntity> entities = people; // OK -- Person derives from BaseEntity
// List<T> is NOT covariant (T is used for both in and out)
List<Person> peopleList = new List<Person>();
// List<BaseEntity> entityList = peopleList; // Compile error!
// Action<in T> is contravariant
Action<BaseEntity> printEntity = e => Console.WriteLine(e.Id);
Action<Person> printPerson = printEntity; // OK -- can handle any BaseEntity, so can handle Person
- This is why repositories return
IEnumerable<TEntity>rather thanList<TEntity>-- more flexibility for consumers.
default(T) and Common Patterns
default(T)returns the default value for type T:0for int,nullfor reference types,Guid.Emptyfor Guid- Since C# 7.1, just
defaultwhen the compiler can infer the type
public void InsertOrUpdate(TEntity entity) {
if (entity.Id == default) { // Guid.Empty for Guid
// new entity
DbSet.Add(entity);
} else {
// existing entity
DbSet.Update(entity);
}
}
default!-- the!is the null-forgiving operator, not related to generics. Used to suppress nullable warnings on required properties:public string Name { get; set; } = default!;
How Generics Work (CLR)
- C# generics are preserved at runtime -- the CLR knows
List<int>andList<string>are different types - Value types (int, Guid, struct) -- the CLR generates specialized native code for each.
List<int>stores actual integers, no boxing. - Reference types (Person, string) -- the CLR shares one implementation, since all references are the same size (pointer).
List<Person>andList<string>share compiled code. - Java comparison -- Java erases generics at compile time (type erasure). At runtime,
List<Integer>is justList<Object>. C# does not erase -- this is why C# generics are faster and type-safer at runtime. - You can check generic types at runtime:
typeof(List<>)(open),typeof(List<int>)(closed)
Built-in Generic Types You Already Use
List<T>,IList<T>,ICollection<T>-- collectionsDictionary<TKey, TValue>-- key-value storeIEnumerable<T>,IQueryable<T>-- used in repos for returning dataDbSet<TEntity>-- EF Core's generic set, essentially a built-in repositoryTask<T>-- async return valuesNullable<T>(i.e.,int?) -- makes value types nullableAction<T>,Func<T, TResult>-- generic delegatesExpression<Func<T, bool>>-- used in LINQ/repo Where clauses
- When you write
DbSet<Person>, you are using a generic class. - When you write
IRepository<TEntity>, you are creating one.
When NOT to Use Generics
- Generics are powerful, but not always the right tool
- Use when: multiple types need the exact same operations (CRUD repos, collections, mappers)
- Don't use when: every implementation has completely different logic -- you don't have a generic pattern, you have unrelated classes forced into a shared interface
- If every generic subclass overrides every method differently, the abstraction is not earning its keep
- YAGNI -- don't make something generic until you have at least 2 concrete cases that share real behavior
- AI tools love to over-abstract. During defense, you must explain why something is generic, not just that it compiles.
Generics - Benefits
- Reusability -- create a method or a class that can be reused with different types in several places.
- Type safety -- the compiler catches type mismatches at compile time. If your repository is
IRepository<Person>, you cannot accidentally pass aContactTypeto it. No runtime casting needed. - No Boxing/Unboxing -- Boxing/Unboxing are costly operations, and it is always better to not rely on them heavily in your code.
List<int>stores actual integers, not boxed objects. - Naming convention --
Tfor a single type parameter. Descriptive names withTprefix for multiple:TEntity,TKey,TValue,TDbContext. The naming rule is no different than for naming classes.
Preview: Repository Pattern
In the next lecture, generics enable this entire architecture:
// Generic contract
public interface IRepository<TEntity> where TEntity : BaseEntity { /* CRUD */ }
// Generic implementation -- write once
public class EFRepository<TEntity> : IRepository<TEntity>
where TEntity : BaseEntity { /* ... */ }
// Specific contract -- add custom queries
public interface IPersonRepository : IRepository<Person> { /* ... */ }
// Specific implementation
public class PersonEFRepository : EFRepository<Person>, IPersonRepository { /* ... */ }
// Unit of Work -- also generic
public class EFBaseUOW<TDbContext> where TDbContext : DbContext { /* ... */ }
// DI registration -- generics in action
builder.Services.AddScoped<IPersonRepository, PersonEFRepository>();
- One generic base class. Dozens of entity types. Zero code duplication.
Self preparation QA
Be prepared to explain topics like these:
- Why use generics in repository and service patterns? — Generics let you write CRUD logic once (
EFRepository<TEntity>) and reuse it for every entity type. Without generics, you would write nearly identical classes for each entity. - What does the constraint
where TEntity : BaseEntityenable? — It tells the compiler thatTEntityhas anIdproperty, allowing the generic repository to write methods likeFindById(Guid id)without knowing the specific entity type. - What is the difference between covariance (
out T) and contravariance (in T)? —out Tmeans the type only comes out (return values).in Tmeans the type only goes in (parameters). This is why repositories returnIEnumerable<T>rather thanList<T>. - How do C# generics differ from Java generics? — C# generics are preserved at runtime (reification). Java erases generics at compile time (type erasure). C# generics are faster and type-safer.
- When should you NOT use generics? — When every implementation has completely different logic. If every generic subclass overrides every method differently, the abstraction adds complexity without reducing duplication. Apply YAGNI.
- What does
default(T)return and how is it used in repositories? —default(T)returns0for int,nullfor reference types,Guid.Emptyfor Guid. In repositories,if (entity.Id == default)checks whether an entity is new vs existing.