Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 090 / 100

Generics and constraints that pay off: a practical guide

Write reusable code without losing type safety.

Expona Admin7 min read

Generics and constraints that pay off

After 90 days of building production systems, you've seen the cost of repeating code: bugs multiply, maintenance becomes a nightmare, and new team members waste time hunting inconsistencies. Generics and constraints that pay off is about writing reusable code without losing type safety—and knowing exactly when to apply generics versus when a concrete implementation wins. I've learned this the hard way, shipping features that looked elegant until a type constraint came back to bite me in production.

Constraints communicate intent

A constraint like where T : class, IEntity is not noise. It's a contract that tells every reader—and the compiler—exactly what T can do. Without it, your generic type parameter is a black box. With it, you've built a guardrail that prevents misuse before it reaches code review.

I learned this building a domain event handler pipeline. Early versions looked like this:

csharp
public interface IDomainEventHandler<T>
{
    Task Handle(T evt);
}

public class EventDispatcher<T>
{
    public async Task Dispatch(T evt)
    {
        // What can we assume about T here?
        // Nothing. This is dangerous.
    }
}

The moment I added a constraint, everything became clearer:

csharp
public interface IDomainEvent
{
    Guid AggregateId { get; }
    DateTime OccurredAt { get; }
}

public interface IDomainEventHandler<T> where T : IDomainEvent
{
    Task Handle(T evt);
}

public class EventDispatcher<T> where T : IDomainEvent
{
    public async Task Dispatch(T evt)
    {
        // Now we know T has AggregateId and OccurredAt.
        // Logging, audit trails, and retry logic become straightforward.
        _logger.LogInformation("Event {EventType} for aggregate {Id}", 
            typeof(T).Name, evt.AggregateId);
    }
}

Multiple constraints stack naturally. where T : class, IEntity means reference type and implements IEntity. where T : struct, IComparable<T> enforces a value type that supports comparison. These aren't restrictions that limit your design—they're guardrails that prevent the next developer from passing in something that doesn't make sense.

The generic repository myth

Every developer writes a generic repository at some point. I did. It felt powerful: one base class, one SaveAsync, one GetByIdAsync, done. Then queries grew. Some entities needed soft deletes, others didn't. Some needed tenant filtering; others didn't. Some queries were heavy and deserved caching; others weren't. By the time I'd added ten overloads, I'd built a leaky abstraction that hid real complexity behind a false simplicity.

Here's the trap:

csharp
// The generic repository lie
public interface IRepository<T> where T : Entity
{
    IQueryable<T> AsQueryable();
    Task<T> GetByIdAsync(int id);
    Task SaveAsync(T entity);
}

// In your service
var customer = await _customerRepo.AsQueryable()
    .Where(c => c.Email == email)
    .FirstOrDefaultAsync();

// Now every caller is writing queries. The repository is just a thin wrapper.
// Why not just inject DbContext?

Instead, I've moved to focused query handlers. Each handler owns one responsibility:

csharp
public interface IQuery<TResult> { }

public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    Task<TResult> Handle(TQuery query);
}

// Specific handler for customer lookup
public class GetCustomerByEmailQuery : IQuery<Customer>
{
    public string Email { get; init; }
}

public class GetCustomerByEmailHandler : IQueryHandler<GetCustomerByEmailQuery, Customer>
{
    private readonly AppDbContext _db;

    public async Task<Customer> Handle(GetCustomerByEmailQuery q)
    {
        return await _db.Customers
            .AsNoTracking()
            .Where(c => c.Email == q.Email && c.IsActive)
            .FirstOrDefaultAsync();
    }
}

This is verbose—until you realize every query is testable in isolation, queryable without ceremony, and free from hidden abstractions. One generic repository tried to handle everything. Many focused handlers handle exactly what they need to.

Generic math with INumber<T>

C# 11 introduced static abstract members, and INumber<T> is one of the few places where generics genuinely save lines of code without sacrificing clarity. If you're computing an average or sum, you no longer need separate methods for int, decimal, double, and float.

Here's what becomes possible:

csharp
public static class MathExtensions
{
    public static T Sum<T>(this IEnumerable<T> values) 
        where T : INumber<T>
    {
        var total = T.Zero;
        foreach (var value in values)
            total += value;
        return total;
    }

    public static T Average<T>(this IEnumerable<T> values) 
        where T : INumber<T>
    {
        var items = values.ToList();
        if (items.Count == 0) throw new InvalidOperationException("Empty sequence");
        return items.Sum() / T.CreateChecked(items.Count);
    }
}

// In your pricing or reporting service
var dailyRevenues = new decimal[] { 100m, 250m, 175m };
var total = dailyRevenues.Sum(); // Works. Type-safe. Generic.
var avg = dailyRevenues.Average(); // Also works for decimal, int, double, etc.

This isn't just less code—it's one place to fix a bug or optimize. In a trading or payment system, that matters. I've used this pattern for commission calculations, risk aggregation, and settlement fees. Every numeric type benefits from the same logic without casting or reflection.

Know when to stop

Generics are powerful. That doesn't mean every design problem is a generic problem. I once designed a payment processing pipeline with seven type parameters:

csharp
public class PaymentProcessor<TPayment, TGateway, TRequest, TResponse, TEvent, TLogger, TCache>
    where TPayment : class, IPayment
    where TGateway : IGateway
    // ... more constraints ...
{
    // Hundreds of lines that nobody understands
}

It was a masterpiece of abstraction. It was also unmaintainable. A new developer joining the team couldn't reason about what types to pass without reading the entire class. Debugging was a nightmare. The code looked clever, and that was the problem.

I rewrote it as three concrete classes, one for each payment method (card, bank transfer, wallet). Each was 200 lines, crystal clear, and testable without a PhD. Three concrete classes beat one generic class with five type parameters. Every time.

A practical rule: if you're writing more than three constraints, or if understanding the generic requires reading the entire implementation, you've probably gone too far. Two, maybe three type parameters is the sweet spot. After that, consider whether a strategy pattern, dependency injection, or multiple concrete classes would be clearer.

Common mistakes

  • Constraining too late: Write constraints upfront, not after you've written the implementation. Constraints are part of your design. where T : class is not a decoration; it's a decision.
  • Over-engineering the repository: A "reusable" repository that tries to handle every query pattern ends up handling none well. Specific query handlers win.
  • Forgetting about variance: IEnumerable<T> is covariant; IList<T> is invariant. Know the difference or you'll hit surprising compiler errors.
  • Using generics to defer decisions: Sometimes a concrete type is simpler than a generic type parameter. Don't use generics because you can; use them because they solve a real problem.
  • Nested generics without documentation: Dictionary<string, List<Func<T, Task<IEnumerable<TResult>>>>> might be valid, but it's unreadable. Create a type alias or concrete type instead.

Key takeaways

  • Constraints are guardrails. where T : IEntity tells readers and the compiler what T must be capable of.
  • A generic repository with IQueryable isn't a repository—it's a DbContext wrapper. Use focused query handlers instead.
  • INumber<T> is a legitimate reason for generics. Use it for math operations across numeric types.
  • If a generic needs more than three type parameters, two concrete classes are probably clearer and easier to maintain.
  • Constraints up front, concrete types when ambiguous, and always ask whether generics actually solve the problem or just feel clever.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles