Generic Interfaces, Covariance and Contravariance

Generic interfaces are interfaces with type parameters — IRepository<T>, ILogger<T>, IOptions<T>. They combine the flexibility of generics with the contract-enforcement of interfaces and are the backbone of ASP.NET Core’s DI system. Variance — covariance and contravariance — describes whether a generic type can be used in place of a more-general or more-specific version, and is what allows IEnumerable<string> to be assigned to a variable of type IEnumerable<object>.

Generic Interfaces

// ── Generic interface with type parameter ─────────────────────────────────
public interface IRepository<T> where T : class
{
    Task<T?>             GetByIdAsync(int id);
    Task<IReadOnlyList<T>> GetAllAsync();
    Task<T>              CreateAsync(T entity);
    Task<T>              UpdateAsync(T entity);
    Task                 DeleteAsync(int id);
}

// A generic interface with two type parameters
public interface IConverter<TSource, TDestination>
{
    TDestination Convert(TSource source);
    IReadOnlyList<TDestination> ConvertAll(IEnumerable<TSource> sources);
}

// Implementing a closed (concrete) version of a generic interface
public class PostRepository : IRepository<Post>
{
    private readonly AppDbContext _db;
    public PostRepository(AppDbContext db) => _db = db;

    public async Task<Post?> GetByIdAsync(int id)
        => await _db.Posts.FindAsync(id);

    public async Task<IReadOnlyList<Post>> GetAllAsync()
        => await _db.Posts.ToListAsync();

    public async Task<Post> CreateAsync(Post entity)
    {
        _db.Posts.Add(entity);
        await _db.SaveChangesAsync();
        return entity;
    }

    public async Task<Post> UpdateAsync(Post entity)
    {
        _db.Posts.Update(entity);
        await _db.SaveChangesAsync();
        return entity;
    }

    public async Task DeleteAsync(int id)
    {
        var entity = await _db.Posts.FindAsync(id);
        if (entity is not null) { _db.Posts.Remove(entity); await _db.SaveChangesAsync(); }
    }
}

// Registration in DI
// builder.Services.AddScoped<IRepository<Post>, PostRepository>();
Note: ASP.NET Core’s DI container fully supports generic type registration. You can register open generics (the pattern without a specific type argument): builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)). When code requests IRepository<Post>, the container creates EfRepository<Post>; when it requests IRepository<User>, it creates EfRepository<User>. This eliminates the need to register each entity’s repository individually — one registration covers all entity types.
Tip: ILogger<T> is the most commonly used generic interface in ASP.NET Core. The type parameter T is the class that is logging — ILogger<PostService> creates log entries tagged with the full class name. This is injected automatically by the DI container — just declare ILogger<PostService> logger as a constructor parameter and the framework provides it. You do not register it manually; ASP.NET Core registers the entire logging infrastructure including generic ILogger<T> support.
Warning: Variance only applies to interfaces and delegates — not to classes. You cannot assign a List<string> to a List<object> variable (the List<T> class is invariant) even though you can assign an IEnumerable<string> to an IEnumerable<object> variable (the IEnumerable<T> interface is covariant). This distinction matters when working with return types and parameter types in LINQ, callbacks, and generic service contracts.

Covariance and Contravariance

// ── Covariance (out T) — can be used as a MORE GENERAL type ──────────────
// IEnumerable<out T> — covariant
IEnumerable<string>  strings = new List<string> { "hello", "world" };
IEnumerable<object>  objects = strings;   // ✓ covariant assignment works!
// Because IEnumerable is read-only (only produces T values, never accepts them)
// a string IS an object, so reading strings as objects is safe

// A covariant custom interface
public interface IProducer<out T>
{
    T Produce();
    // void Consume(T item);  // compile error — cannot use T as input with 'out'
}

// ── Contravariance (in T) — can be used as a MORE SPECIFIC type ──────────
// IComparer<in T> — contravariant
IComparer<object> objectComparer = Comparer<object>.Default;
IComparer<string> stringComparer = objectComparer;   // ✓ contravariant assignment works!
// A comparer that handles any object can compare strings (strings are objects)

// Action<in T> is also contravariant
Action<object> handleObject = obj => Console.WriteLine(obj);
Action<string> handleString = handleObject;  // ✓ an action accepting object can accept string

// ── Why this matters in practice ──────────────────────────────────────────
// Methods returning IEnumerable<string> can be assigned to IEnumerable<object> variables
// Useful when passing collections to general-purpose utilities that expect IEnumerable<object>

Common Mistakes

Mistake 1 — Expecting variance to work with List<T> (invariant class)

❌ Wrong — compile error: List<T> is invariant:

List<string> strings = new();
List<object> objects = strings;   // compile error — List is invariant!

✅ Correct — use IEnumerable<T> (covariant) when only reading:

IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings;   // ✓ IEnumerable is covariant

Mistake 2 — Using T as an input parameter in a covariant (out T) interface

❌ Wrong — compile error: cannot use out T as a method parameter:

public interface IBadProducer<out T>
{
    void Consume(T value);  // compile error — out T cannot be an input!

🧠 Test Yourself

Why can you assign IEnumerable<string> to IEnumerable<object> but not List<string> to List<object>?