Skip to main content

⚡ Part 4: Complex Scenarios with Generics in C#

In Part 1–3, we learned:

  • Basics of generics (methods, classes, constraints)

  • Advanced patterns (delegates, factories, strategies)

  • Real-world use cases (repository, service, caching, API responses)

Now in Part 4, we’ll handle trickier topics:

  1. Nested Generics

  2. Covariance & Contravariance

  3. Generic Methods in LINQ-style APIs

  4. Performance Considerations with Generics

  5. Common Pitfalls and Best Practices


1) Nested Generics

Sometimes we need a generic type inside another generic type.
This looks confusing at first, but it’s actually quite powerful.


Example: Dictionary of Lists

// A dictionary where the key is string, and the value is a list of integers
Dictionary<string, List<int>> studentMarks = new();

studentMarks["Alice"] = new List<int> { 90, 85, 88 };
studentMarks["Bob"] = new List<int> { 70, 75, 80 };

foreach (var kv in studentMarks)
{
    Console.WriteLine($"{kv.Key}: {string.Join(", ", kv.Value)}");
}

👉 Why?
Because we want to store multiple values for one key.

  • string → student name

  • List<int> → their marks


Example: Generic Wrapper Around Nested Generic

// A generic container that stores key-value pairs where value itself is generic
public class MultiValueDictionary<TKey, TValue>
{
    private readonly Dictionary<TKey, List<TValue>> _dict = new();

    public void Add(TKey key, TValue value)
    {
        if (!_dict.ContainsKey(key))
            _dict[key] = new List<TValue>();

        _dict[key].Add(value);
    }

    public IEnumerable<TValue> Get(TKey key) =>
        _dict.ContainsKey(key) ? _dict[key] : Enumerable.Empty<TValue>();
}
// Usage
var marks = new MultiValueDictionary<string, int>();
marks.Add("Alice", 90);
marks.Add("Alice", 85);
marks.Add("Bob", 70);

foreach (var m in marks.Get("Alice"))
    Console.WriteLine(m); // 90, 85

✅ Nested generics let us create complex data structures like dictionaries of lists, sets of tuples, etc.


2) Covariance & Contravariance

These scary words just mean “Can I substitute a derived type for a base type (and vice versa) when using generics?”.


🔹 Covariance (out)

Covariance means you can use a more derived type instead of a base type when returning values.

// Read-only interface (only returns T, never accepts T)
public interface IReadOnlyRepository<out T>
{
    T GetById(int id);
}

public class CustomerRepo : IReadOnlyRepository<Customer>
{
    public Customer GetById(int id) => new Customer { Id = id, Name = "Alice" };
}
// Because of "out T", we can assign CustomerRepo to a variable expecting IReadOnlyRepository<IEntity>
IReadOnlyRepository<IEntity> repo = new CustomerRepo();
IEntity entity = repo.GetById(1);
Console.WriteLine(entity.Id);

👉 Why?
Because it’s safe: if an interface only returns T, substituting Customer for IEntity doesn’t break anything.


🔹 Contravariance (in)

Contravariance means you can use a less derived type instead of a more derived type when passing parameters.

// Write-only interface (only accepts T, never returns T)
public interface IWriter<in T>
{
    void Write(T item);
}

public class EntityWriter : IWriter<IEntity>
{
    public void Write(IEntity entity) =>
        Console.WriteLine($"Writing entity with Id {entity.Id}");
}
// Because of "in T", we can assign IWriter<IEntity> to a variable expecting IWriter<Customer>
IWriter<Customer> writer = new EntityWriter();
writer.Write(new Customer { Id = 1, Name = "Bob" });

👉 Why?
Because it’s safe: if a method only accepts T, passing a Customer where IEntity is expected is fine.


💡 Easy memory trick:

  • out = "output only" → covariance

  • in = "input only" → contravariance


3) Generic Methods in LINQ-style APIs

Generics power LINQ methods like Where, Select, OrderBy.


Example: Custom Generic Filter Method

public static class Extensions
{
    // A generic filter method similar to LINQ's Where
    public static IEnumerable<T> Filter<T>(this IEnumerable<T> source, Func<T, bool> predicate)
    {
        foreach (var item in source)
        {
            if (predicate(item)) yield return item;
        }
    }
}
// Usage
var numbers = new List<int> { 1, 2, 3, 4, 5 };
var evenNumbers = numbers.Filter(n => n % 2 == 0);

foreach (var n in evenNumbers)
    Console.WriteLine(n); // 2, 4

👉 Why generics here?
Because Filter<T> works for any collection (ints, strings, customers) with zero code duplication.


4) Performance Considerations

Generics in C# are not like Java generics — they are implemented with reified types (the actual type is preserved at runtime).

This means:

  • Value types (int, double, struct) → No boxing/unboxing overhead in generic collections (e.g., List<int> is efficient).

  • Reference types (classes) → Only references are stored, so no extra overhead.


Example: Boxing Problem Without Generics

ArrayList list = new ArrayList(); // Non-generic collection
list.Add(10); // int boxed into object
int num = (int)list[0]; // unboxed back

👉 Boxing/unboxing slows down performance.


Example: No Boxing With Generics

List<int> list = new List<int>();
list.Add(10); // Stored directly (no boxing)
int num = list[0]; // Retrieved directly

✅ Generics are faster and safer.


5) Common Pitfalls & Best Practices

  1. Don’t overuse generics

    • If a class/method is only used with one type, no need to make it generic.

    • Example: CustomerPrinter doesn’t need <T> if it always prints Customer.

  2. Use constraints wisely

    • where T : class (only reference types)

    • where T : struct (only value types)

    • where T : new() (must have default constructor)

    • Prevents runtime bugs by catching errors at compile time.

  3. Be careful with nested generics

    • Too much nesting (Dictionary<string, List<Tuple<int, string>>>) can make code unreadable.

    • Use typedef (using) to simplify:

    using StudentMarks = Dictionary<string, List<int>>;
    
  4. Prefer interfaces with variance (in, out) when designing libraries.

    • Makes APIs more flexible.


🎯 Key Takeaways from Part 4

  • Nested Generics → Build powerful structures like dictionary of lists.

  • Covariance/Contravarianceout = return types, in = parameter types.

  • Generic LINQ-style Methods → Enable reusable functional patterns (Filter, Select).

  • Performance → Generics avoid boxing/unboxing, making collections faster.

  • Best Practices → Use constraints, avoid over-complication, prefer variance in APIs.


✅ With this, our Generics in C# series (Parts 1–4) is complete!
You now know everything from basics to advanced enterprise usage.


Comments

Popular posts from this blog

🌟 Dot net Microservices interview questions

Here is a comprehensive list of 200 .NET microservices coding questions covering all core microservices concepts and cross-cutting concerns relevant for designing, building, deploying, and maintaining .NET-based distributed systems. 🧩 A. Microservices Fundamentals (20) Build a microservice in .NET 8 that exposes a simple CRUD API. Implement communication between two microservices using REST. How would you design microservices for an e-commerce application? Create a microservice that handles user registration and login. How do you isolate domain logic in a microservice? How to apply the "Single Responsibility Principle" in microservices? Design a service registry/discovery mechanism using custom middleware. Implement a service that handles file uploads and metadata separately. Build a stateless microservice and explain its benefits. Implement health check endpoints in .NET 8. Demonstrate versioning in a microservice API. Add Swagger/OpenAPI support to your m...

⚡ Part 1: Introduction to Generics in C#

🌍 Why Do We Need Generics? Imagine you want to create a stack (like a pile of books 📚): You can push items on top You can pop items off the top If we write a stack for integers : public class IntStack { private int[] items = new int[10]; private int index = 0; public void Push(int item) => items[index++] = item; public int Pop() => items[--index]; } 👉 Problem: This only works for int . What if we want a string stack ? Or a Customer stack ? We’d have to duplicate code for every type. 😢 ✅ Solution: Generics Generics let us create type-safe reusable code without duplication. We can say: “I don’t care what type it is yet — I’ll decide later.” 1) Generic Classes Here’s a generic stack : // Generic class "Stack<T>" // The <T> is a placeholder for any type public class Stack<T> { private T[] items = new T[10]; // Array of type T private int index = 0; // Push adds an item of type T public void P...

🚪 Part 9: API Gateway for .NET 8 Microservices (Ocelot & YARP)

Once you have multiple microservices (Products, Orders, Payments…), exposing each one directly to clients gets messy: Different base URLs Duplicated auth logic No unified rate limiting / caching Hard to evolve routes or aggregate data 👉 Enter the API Gateway — your single front door for all microservices. An API Gateway handles: ✅ Routing & path rewriting ✅ Load balancing, retries, circuit breakers ✅ Authentication & Authorization (JWT, OAuth2) ✅ Rate limiting & caching ✅ Aggregation (compose results from multiple services) In this post we’ll implement two strong options: Ocelot → config-driven, mature, DevOps-friendly YARP (Yet Another Reverse Proxy) → Microsoft’s code-first, extensible gateway ⚖️ Ocelot vs YARP — When to Choose Ocelot → JSON config, minimal C#, built-in QoS (rate limit, circuit breaker). Perfect for teams that like DevOps config-as-code. YARP → full C# control, middleware-friendly, can embed into broader apps (e.g. add dashb...