Generic interfaces: type-safe contracts, avoiding boxing, implementing multiple closed interfaces, explicit implementation and designing generic abstractions.
A generic interface is a contract with a type parameter, like IComparer<T> or IEquatable<T>. They exist because the old object-based interfaces forced casts and boxed structs: Dictionary<TKey,TValue> uses EqualityComparer<T>.Default, which calls IEquatable<T>.Equals with no boxing. A type can implement several closed versions of the same interface, usually explicitly, and generic interfaces, unlike classes, can be covariant or contravariant. When I design one, like IHandler<TCommand>, I keep it small and make T carry real meaning.
A generic interface is an interface with type parameters: IEquatable<T>, IComparable<T>, IEnumerable<T>, IRepository<T>. It gives a type-safe contract that works across types without casts or boxing. I'd explain why the generic versions exist alongside the old non-generic ones, how one type can implement several closed versions, and how I design my own — including the trap of over-generic repositories.
The non-generic interfaces (IEquatable doesn't exist non-generic, but IComparable, IEnumerable, IList, ICollection do) date from .NET 1.0, before generics. They use object:
public interface IComparable { int CompareTo(object? obj); } // casts, boxing for structs
public interface IComparable<in T> { int CompareTo(T? other); } // typed, no boxing
The generic versions were added in .NET 2.0 for type safety and performance:
IComparable<int>.CompareTo(int) takes the value directly; IComparable.CompareTo(object) boxes the argument.IEnumerable<T> gives typed foreach and LINQ.The non-generic versions remain for backward compatibility (old APIs like ArrayList.Sort, data binding, reflection-based frameworks), and generic interfaces often inherit the non-generic one (IEnumerable<T> : IEnumerable) so typed collections still work with legacy consumers.
IEquatable<T> exists because object.Equals(object) boxes value types and needs a type check; EqualityComparer<T>.Default detects IEquatable<T> and calls the typed method — which is why structs used as dictionary keys should implement it.
A type can implement the same generic interface for different type arguments:
public sealed class Temperature : IEquatable<Temperature>, IComparable<Temperature>, IComparable<double>
{
public double Celsius { get; }
public bool Equals(Temperature? other) => other is not null && Celsius.Equals(other.Celsius);
public int CompareTo(Temperature? other) => Celsius.CompareTo(other?.Celsius ?? double.NegativeInfinity);
public int CompareTo(double other) => Celsius.CompareTo(other);
}
public sealed class OrderEventHandler : IHandler<OrderPlaced>, IHandler<OrderShipped>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct) => ...;
public Task HandleAsync(OrderShipped e, CancellationToken ct) => ...;
}
Each closed interface (IHandler<OrderPlaced>, IHandler<OrderShipped>) is a distinct interface; overloads with different parameter types implement each. If two closed versions have members with the same signature (e.g. a method returning T with no parameters), use explicit interface implementation to disambiguate. A type can't implement two versions that could unify for some type argument (e.g. IFoo<T> and IFoo<int> on a generic class C<T>) — a compile error.
IRepository<T> done carefullypublic interface IRepository<TAggregate, TId>
where TAggregate : AggregateRoot<TId>
where TId : notnull
{
Task<TAggregate?> FindAsync(TId id, CancellationToken ct);
void Add(TAggregate aggregate);
void Remove(TAggregate aggregate);
}
public interface IOrderRepository : IRepository<Order, OrderId>
{
Task<IReadOnlyList<Order>> GetUnshippedAsync(DateOnly before, CancellationToken ct); // specific queries
}
Guidelines:
T must be (aggregate roots, IDs).Find(Expression<Func<T,bool>>) or IQueryable<T> GetAll().IReader<out T> that only returns T can be covariant; an IHandler<in T> that only consumes T can be contravariant.The common mistake is a giant generic repository (GetAll, Find(predicate), Update, Delete, Query()) wrapping EF Core: it leaks EF details, encourages loading too much, and adds a shallow abstraction over something that's already a repository/unit of work.
A team had IRepository<T> with IQueryable<T> Query() used everywhere. Services built ad-hoc LINQ queries that failed at run time when EF couldn't translate them, and N+1 queries crept in. We replaced it with aggregate-specific repositories exposing intention-revealing async methods, kept a tiny generic base for FindAsync/Add, and moved reporting queries to dedicated query handlers with projections. Run-time translation errors disappeared and query performance became reviewable.
Probes whether the candidate understands what generic interfaces buy (type safety, no boxing), how they interact with collections, and whether they can design one without leaking abstractions.
Green flags: links IEquatable<T> to EqualityComparer<T>.Default and boxing; knows only interfaces and delegates are variant; critiques IRepository<T> with IQueryable.
Red flags: thinks generic and non-generic interfaces are interchangeable; never heard of explicit implementation; cannot name a BCL generic interface beyond IEnumerable<T>.
## 1. Variance in generic interfaces
- `IEnumerable
namespace Generics.Interfaces;
public interface IMessageHandler<in TMessage>
{
Task HandleAsync(TMessage message, CancellationToken ct);
}
public readonly record struct OrderPlaced(Guid OrderId, decimal Total);
public readonly record struct OrderCancelled(Guid OrderId, string Reason);
public sealed class OrderProjection : IMessageHandler<OrderPlaced>, IMessageHandler<OrderCancelled>
{
public Task HandleAsync(OrderPlaced message, CancellationToken ct)
{
Console.WriteLine($"Placed {message.OrderId} for {message.Total:C}");
return Task.CompletedTask;
}
public Task HandleAsync(OrderCancelled message, CancellationToken ct)
{
Console.WriteLine($"Cancelled {message.OrderId}: {message.Reason}");
return Task.CompletedTask;
}
}
public readonly struct Money(decimal amount, string currency) : IEquatable<Money>, IComparable<Money>
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
public bool Equals(Money other) => Amount == other.Amount && Currency == other.Currency;
public override bool Equals(object? obj) => obj is Money other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public int CompareTo(Money other) => Currency == other.Currency
? Amount.CompareTo(other.Amount)
: throw new InvalidOperationException("Cannot compare different currencies.");
}
public static class Program
{
public static async Task Main()
{
var projection = new OrderProjection();
await projection.HandleAsync(new OrderPlaced(Guid.NewGuid(), 99.95m), CancellationToken.None);
await projection.HandleAsync(new OrderCancelled(Guid.NewGuid(), "Customer request"), CancellationToken.None);
List<Money> prices = [new(10m, "AUD"), new(25m, "AUD")];
Console.WriteLine(prices.Contains(new Money(25m, "AUD"))); // IEquatable<Money>, no boxing
prices.Sort(); // IComparable<Money>
}
}
What happens: types implement IComparable or override Equals(object) without generic versions.
Why it is wrong: boxing and casts on every comparison.
Do instead: implement IEquatable<T>/IComparable<T> (and keep non-generic for compatibility if needed).
What happens: IRepository<T> with GetAll, Find(predicate), Query(), Update, Delete.
Why it is wrong: leaky, shallow abstraction over EF Core; encourages inefficient queries.
Do instead: small generic core plus aggregate-specific repositories or direct DbContext use.
IQueryable<T> from generic interfacesWhat happens: callers compose arbitrary LINQ. Why it is wrong: run-time translation failures and unpredictable SQL. Do instead: expose specific async query methods.
What happens: IEquatable<T> implemented but Equals(object) and GetHashCode not overridden.
Why it is wrong: different code paths give different answers.
Do instead: keep all equality members consistent (or use records).
What happens: read-only generic interfaces declared invariant.
Why it is wrong: consumers can't pass IReader<Dog> as IReader<Animal>.
Do instead: add out/in where valid.
What happens: implementing two closed versions with clashing members implicitly. Why it is wrong: compile errors or confusing overloads. Do instead: use explicit interface implementation.
What happens: IPublisher<T> registered per message type.
Why it is wrong: registration and injection explosion.
Do instead: PublishAsync<T>(T message) on a single interface.
What happens: constraints that require base classes when an interface would do. Why it is wrong: limits reuse and prevents structs. Do instead: constrain to the minimal interface needed.
Generic interfaces are where API design and performance meet. IEquatable<T> and IComparable<T> on value types remove boxing in every collection, and a constrained generic call lets the JIT inline the struct's method. On the design side, I treat a generic interface as a promise that every T behaves the same; when implementations start special-casing types, the interface is the wrong abstraction. I also think about variance at publication time, because handler and comparer interfaces almost always want in T.
Covariance means a generic type keeps the direction of inheritance: because string is an object, IEnumerable<string> can be used as IEnumerable<object>. You declare it with out T, only on interfaces and delegates, and T may only be returned, never accepted. That is why IReadOnlyList<T> is covariant but List<T> is not: List<object>.Add(42) would break a list of strings. It only works with reference types, so IEnumerable<int> does not convert to IEnumerable<object>.
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
An interface is a contract: a set of members a type promises to provide, with no instance state. A class or struct can implement many interfaces. Since C# 8 an interface can carry default implementations (used only through the interface, and they need .NET Core 3.0+), plus static, private and protected members. Since C# 11 it can declare static abstract / static virtual members, which power generic math (INumber<T>, T.Zero). Explicit implementation (void IDisposable.Dispose()) hides a member unless you cast. Calls through an interface use interface dispatch, which the JIT can often devirtualise. Casting a struct to an interface boxes it; a generic constraint (where T : IComparable<T>) avoids the box.
How to tell it (STAR)
**S:** Our pricing engine was spending [percentage] of CPU in garbage collection. **T:** Find the cause without a redesign. **A:** I traced allocations, found boxed dictionary keys and fixed the key type by implementing `IEquatable<T>` via a record struct, then added an analyser rule. **R:** Allocations on that path dropped to zero and p99 latency improved by [amount].
Last updated 30 Oct 2026.