A generic interface is a contract parameterized by one or more types. IRepository<T> doesn't say "I store things," it says "I store things of type T," and the T shows up in every member that handles the data. The BCL is full of them (IEnumerable<T>, IComparer<T>, IEquatable<T>, and more), and your own code will be too once you start writing reusable services. This chapter covers how to declare a generic interface, the three ways to implement one, the awkward case of implementing the same generic interface for two different closed types on a single class, and a quick tour of the generic interfaces in the BCL you'll touch most often.
A generic interface declaration looks like a regular interface declaration with a type parameter list after the name. Every member that mentions the type parameter uses it like a real type, even though the actual type isn't known until someone implements or constructs the interface.
A small repository contract for an e-commerce backend:
Four members, and three of them mention T. The interface doesn't know whether T is a Product, a Customer, or an Order. It just promises that whoever implements it will provide those four operations for whatever T they pick.
Generic interfaces have the same syntactic features as generic classes and generic methods, so the recap is short: type parameter names in angle brackets after the interface name, conventional names like T, TKey, TValue, TItem, and one or more parameters separated by commas. Constraints (where T : class, where T : IComparable<T>, and others) are also allowed.
A generic interface, by itself, isn't a usable type. You can't write IRepository<> and expect to assign a value to it. The interface only becomes a concrete contract once T is fixed, either by closing it (IRepository<Product>) or by passing the type parameter through a generic class or method (Repository<T> : IRepository<T>). Both patterns matter, and the rest of this chapter walks through them.
Generic interface dispatch has the same cost as non-generic interface dispatch: a virtual call through the interface method table. Where generics pay off is at the JIT layer. For value-type T, the runtime generates a distinct, monomorphized version of the implementing methods, so there's no boxing of T on the call path. For reference-type T, all reference instantiations share a single compiled body. Neither case is slower than a non-generic interface.
The simplest implementation strategy is to pick a specific T and lock it in at the class level. The class name is no longer generic; it just satisfies one specific closed form of the interface.
ProductRepository declares : IRepository<Product>, and the compiler now requires every member of IRepository<Product> to exist on the class with T substituted by Product. The method signatures say Product instead of T, but the names and shapes match the interface. The variable repo is typed as IRepository<Product>, which is the contract; the runtime object is the ProductRepository instance.
The benefit is the same as with any interface: callers can depend on IRepository<Product> and never see ProductRepository directly. Swap in an InMemoryProductRepository, a SqlProductRepository, or a FakeProductRepository for tests, and nothing else changes. This is the heart of dependency injection: code asks for what it needs (IRepository<Product>) and the container provides whatever concrete implementation is configured.
The other strategy is to make the implementing class itself generic and pass T through to the interface. The class name carries a type parameter, and that parameter is what closes the interface.
Repository<T> is itself generic. When you write new Repository<Customer>(...), the compiler substitutes Customer for T everywhere, and the class becomes an IRepository<Customer>. The same source code now backs both Repository<Customer> and Repository<Order>. One generic class, many closed forms, all of them satisfying their respective closed IRepository<...> contracts.
The where T : class constraint is what makes the T? return type meaningful as a nullable reference. The Func<T, int> idSelector parameter is the small bit of glue that lets one generic class work for any entity that has an integer ID, without forcing the entity types to share a base class or interface for "has an ID."
This pattern shows up everywhere in real code. List<T> : IList<T>. Dictionary<TKey, TValue> : IDictionary<TKey, TValue>. HashSet<T> : ISet<T>. The collection class carries the type parameter, the interface gets closed by that parameter, and consumers see whichever form of the interface they ask for.
The diagram shows the same IRepository<T> contract being satisfied three ways. ProductRepository and CustomerRepository close the type parameter to a specific entity. Repository<T> stays generic and passes T through. From a caller's perspective, all three are interchangeable as long as the closed type matches.
Here's where generic interfaces get interesting. A single class can implement the same generic interface multiple times, as long as each implementation closes the type parameter to a different type. The compiler treats IRepository<Product> and IRepository<Customer> as two completely different interfaces, even though they came from the same IRepository<T> declaration.
The catch is method-name collision. IRepository<Product>.Add(Product) and IRepository<Customer>.Add(Customer) have the same method name and a single argument each. Method overloading by parameter type lets these coexist when the closed types differ. But for parameterless members like GetAll(), or members like Remove(int) that have signatures identical across both forms, the compiler can't resolve which one you mean. The fix is explicit interface implementation.
The Add methods are normal public members because the compiler can pick one based on the argument type. store.Add(new Product(...)) resolves to the Product overload by overload resolution rules. GetById, GetAll, and Remove are written with the interface name as a qualifier: Product? IRepository<Product>.GetById(int id). Those are explicit interface implementations. They aren't accessible through the class type directly. To call them, you cast the instance to the specific closed interface.
The casts in IRepository<Product> productView = store; are implicit because MultiStore is declared to implement both interfaces. The runtime knows which method to dispatch to based on the static type of the variable.
This pattern is correct and works, but it's awkward. Callers can't write store.GetById(1) because the class doesn't have a non-explicit GetById. They have to know which interface view they want. In practice, most codebases avoid this situation by giving each entity its own dedicated repository class (ProductRepository, CustomerRepository) and composing them rather than stacking interface implementations onto a single type. Multi-implementation is a signal to revisit the design.
Explicit interface implementation has the same dispatch cost as regular interface implementation: one indirect call through the interface method table. There's no extra penalty. The cost is in readability and ergonomics, not in CPU cycles.
The interface itself can have a type parameter, and individual members can have their own type parameters on top of that. This sounds like a recipe for confusion, but it's occasionally the standard way to express a contract.
Project<TResult> is a generic method living inside a generic interface. The outer T is fixed at the interface level (here, Product). The inner TResult is fixed at each call site (here, string for the lambda p => p.Name.ToUpper()). The two type parameters are independent. The interface owns T; the method owns TResult.
The point for this chapter is that nothing about putting method-level type parameters inside a generic interface changes how they behave. Each level of generic-ness is resolved separately.
The BCL ships dozens of generic interfaces, but a handful do most of the heavy lifting. This is a usage-level tour. For now, just see how each interface looks at the call site and the implementation site.
The floor every collection sits on. Implement it when you want callers to be able to foreach over your type.
IEnumerable<T> inherits from the non-generic IEnumerable, so any implementation has to provide both GetEnumerator() methods. The non-generic one is conventionally written as an explicit implementation that delegates to the generic one. The declaration in the BCL is IEnumerable<out T>, and the _Covariance & Contravariance_ lesson explains what the out means and why it's declared that way; for now, treat it as a normal generic interface.
A separate object that knows how to order two items of type T. Implement it when the comparison logic doesn't belong on the item type itself, or when you need multiple orderings for the same type.
List<T>.Sort accepts any IComparer<T>. The comparer is a separate class from Product, which means you can have a PriceAscendingComparer, a PriceDescendingComparer, and a NameComparer without changing Product itself. This is the "strategy" pattern in interface form.
A separate object that defines what "equal" means for T, used by Dictionary<TKey, TValue>, HashSet<T>, and LINQ operators like Distinct and GroupBy.
HashSet<Customer> would normally use Customer.Equals and Customer.GetHashCode. By passing an IEqualityComparer<Customer> to the constructor, you override that for this particular set without touching the Customer type. The two emails that differ only by case collapse to a single entry.
A self-implemented equality contract. Implement it on the type itself when there's one obvious notion of equality for that type.
IEquatable<T> exists so the runtime can call a strongly-typed Equals(T other) instead of the boxed Equals(object? obj). For value types, this avoids boxing on every equality check. For reference types, it's mostly about clarity: the method signature says exactly what it compares. Records implement IEquatable<T> automatically, which is one of the reasons records show up in so many of the examples in this chapter.
A self-implemented ordering contract. Implement it on the type when there's one obvious "natural" order for that type.
List<T>.Sort() with no argument falls back to IComparable<T> on the elements. The price-ascending order is the type's default order. If you need a different order, fall back to IComparer<T> (above) and pass it as the argument to Sort.
The convention worth memorizing: IComparable<T> is "I know how to compare myself," IComparer<T> is "I know how to compare two of those." Same for IEquatable<T> and IEqualityComparer<T>. Self-implemented for the natural single notion, separate-object for everything else.
Most of the value of generic interfaces lives outside the syntax. The mechanical part (declare, implement, close) is small. The design payoff is what makes them worth using.
Contract by shape, not by class. A generic interface says "anything with this shape, parameterized by T, can plug in here." IRepository<Product> doesn't care whether the implementation is in-memory, SQL-backed, or a mock for tests. The interface specifies the operations and the type they operate on; the implementation can be anything that satisfies the shape.
Swappable implementations. Writing code against IRepository<T> lets you swap implementations without rewriting consumers. The shopping cart service depends on IRepository<Product>. In production it gets a SqlProductRepository. In integration tests it gets an InMemoryProductRepository. In a benchmark it gets a CachedProductRepository. Same caller code in every case.
Dependency injection. This is the same point in DI-flavored language. Frameworks like ASP.NET Core's built-in container or Autofac let you register IRepository<Product> and resolve a concrete implementation when needed. Generic interfaces are the language-level construct that makes this work cleanly. The container can even register an open generic (Repository<>) and resolve closed forms automatically, so a single registration covers IRepository<Product>, IRepository<Customer>, and every other entity type.
Testability. A class that depends on IRepository<Product> can be tested with a hand-rolled stub or a mocking library, without standing up a database. The interface is the seam.
Compile-time guarantees with reuse. Without generics, a single IRepository interface would have to use object everywhere, and every implementation would have to cast back to the entity type. Generic interfaces give you the same reuse with full type safety: the compiler enforces that you can't accidentally pass a Product to a IRepository<Customer>.
The same arguments apply to your own generic interfaces, not just the BCL's. If your code has a "thing I do with type T" pattern, expressing it as an interface (IPriceCalculator<T>, IValidator<T>, IFilter<T>) gives you the same swap-and-test affordances at no cost.
Two completely different pricing rules behind a single interface name. The shopping cart service can ask for IPriceCalculator<Product> for line items and IPriceCalculator<Order> for totals, without knowing or caring how either one computes its result. The _Generic Constraints_ lesson explores how to tighten this contract further when you need to require that T has specific capabilities.
10 quizzes