in Part 3, we’ll apply generics to real-world enterprise scenarios:
Generic Repository Pattern
Generic Service Layer
Generic Caching Utility
Generic API Response Wrapper
Let’s dive in 🚀
1) Generic Repository Pattern
The repository pattern is used to separate data access logic (like Add, Get, Remove) from the rest of the code.
Without a generic repository, you’d write separate repositories for each entity (CustomerRepository, OrderRepository, etc.), which leads to a lot of duplicate code.
Generics solve this by creating one reusable repository for all entities.
Code Walkthrough
// All entities will implement IEntity so that we can guarantee they have an Id property
public interface IEntity
{
int Id { get; set; }
}
👉 Why?
Because in most systems, every object (Customer, Order, Product) has an Id that uniquely identifies it.
By enforcing IEntity, we make sure our repository can always look up objects by Id.
// Repository interface with CRUD-like operations
public interface IRepository<T> where T : class, IEntity
{
void Add(T entity);
void Remove(T entity);
T GetById(int id);
IEnumerable<T> GetAll();
}
👉 Why where T : class, IEntity?
-
classensures that T is a reference type (notintordouble). -
IEntityensures T has anId.
This gives us type safety — the compiler won’t let you useRepository<int>becauseintdoesn’t have an Id.
// In-memory repository implementing IRepository
public class InMemoryRepository<T> : IRepository<T> where T : class, IEntity
{
private readonly List<T> _items = new(); // Local storage (simulates DB)
public void Add(T entity) => _items.Add(entity);
public void Remove(T entity) => _items.Remove(entity);
public T GetById(int id) => _items.FirstOrDefault(e => e.Id == id);
public IEnumerable<T> GetAll() => _items;
}
👉 Why a List<T>?
Because in a real app, you’d use a database (SQL, MongoDB, etc.), but here we simulate a database in memory for learning purposes.
// Entities
public class Customer : IEntity
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Order : IEntity
{
public int Id { get; set; }
public decimal Amount { get; set; }
}
👉 Why both inherit IEntity?
Because that guarantees each entity has an Id property, which our repository needs.
// Usage
var customerRepo = new InMemoryRepository<Customer>();
customerRepo.Add(new Customer { Id = 1, Name = "Alice" });
customerRepo.Add(new Customer { Id = 2, Name = "Bob" });
foreach (var c in customerRepo.GetAll())
Console.WriteLine($"Customer: {c.Name}");
👉 Why does this work?
Because Customer implements IEntity, so the generic repository knows it can safely store it and look it up by Id.
✅ Benefit: We don’t need a CustomerRepository and an OrderRepository. One generic repository works for all.
2) Generic Service Layer
Repositories are about data access.
Services are about business logic (rules, calculations, validations).
Instead of writing CustomerService, OrderService, etc., we can again make a GenericService.
public interface IService<T> where T : class, IEntity
{
void Add(T entity);
T GetById(int id);
IEnumerable<T> GetAll();
}
👉 Why another interface?
This separates the contract (what methods are available) from the implementation (how they’re done).
It also makes unit testing easier because you can swap in fake implementations.
public class GenericService<T> : IService<T> where T : class, IEntity
{
private readonly IRepository<T> _repository;
// Dependency Injection of repository
public GenericService(IRepository<T> repository)
{
_repository = repository;
}
public void Add(T entity) => _repository.Add(entity);
public T GetById(int id) => _repository.GetById(id);
public IEnumerable<T> GetAll() => _repository.GetAll();
}
👉 Why pass in IRepository<T> instead of creating one directly?
This is Dependency Injection (DI). It makes the code more flexible:
-
In dev, you can pass
InMemoryRepository<T> -
In production, you can pass
SqlRepository<T> -
In tests, you can pass
FakeRepository<T>
✅ The service doesn’t care where the data comes from — it just uses the repository.
3) Generic Caching Utility
Caching is storing data temporarily so it can be accessed quickly.
Instead of making separate caches for each type, we build one generic cache.
public class Cache<T>
{
private readonly Dictionary<string, T> _cache = new();
public void Add(string key, T value) => _cache[key] = value;
public T Get(string key) => _cache.TryGetValue(key, out var value) ? value : default;
public bool Exists(string key) => _cache.ContainsKey(key);
}
👉 Why use Dictionary<string, T>?
-
string= key (unique identifier like “user:123”). -
T= the actual cached object (could beCustomer,Order,string, etc.).
👉 Why return default if key not found?
-
If
Tis a class → default isnull. -
If
Tis int → default is0.
This prevents exceptions when the key doesn’t exist.
var stringCache = new Cache<string>();
stringCache.Add("greeting", "Hello Generics!");
Console.WriteLine(stringCache.Get("greeting")); // Hello Generics!
var customerCache = new Cache<Customer>();
customerCache.Add("cust1", new Customer { Id = 1, Name = "David" });
Console.WriteLine(customerCache.Get("cust1").Name); // David
✅ One cache class works for both strings and customers.
4) Generic API Response Wrapper
In APIs, every response usually contains:
-
Success/Failure flag
-
Data (could be any type)
-
Message (for errors/info)
Instead of making separate response types, we build one generic wrapper.
public class ApiResponse<T>
{
public bool Success { get; set; } // Did the operation succeed?
public string Message { get; set; } // Helpful message (error or success info)
public T Data { get; set; } // The actual data
// Helper for success
public static ApiResponse<T> Ok(T data, string message = "")
=> new ApiResponse<T> { Success = true, Data = data, Message = message };
// Helper for failure
public static ApiResponse<T> Fail(string message)
=> new ApiResponse<T> { Success = false, Message = message };
}
👉 Why make Ok and Fail static helpers?
Because it’s easier to create responses without repeating code:
-
ApiResponse<Customer>.Ok(new Customer {...}) -
ApiResponse<Order>.Fail("Order not found")
var successResponse = ApiResponse<Customer>.Ok(new Customer { Id = 1, Name = "Emma" }, "Customer fetched");
Console.WriteLine($"Success: {successResponse.Success}, Data: {successResponse.Data.Name}");
var errorResponse = ApiResponse<Order>.Fail("Order not found");
Console.WriteLine($"Success: {errorResponse.Success}, Error: {errorResponse.Message}");
✅ One consistent response structure for all APIs, regardless of data type.
🎯 Why Write Code Like This?
-
Less Duplication → One generic repository, one service, one cache, one response wrapper.
-
More Reusability → Works for any entity type (
Customer,Order,Product). -
Type Safety → Compiler ensures correct usage (you can’t accidentally store a
stringin anOrderrepository). -
Scalability → Adding new entities (e.g.,
Invoice) requires zero extra repository/service code. -
Consistency → Every API response looks the same, every service follows the same pattern.
Link for part 4👇
👉 Next in 🔗 Part 4, we’ll cover complex scenarios like nested generics, covariance/contravariance, and performance tricks.
Comments
Post a Comment