Don’t Wanna Change Your Repository ? Meet the Last Design Pattern You Will Ever Need
Bloated Repositories are not Inevitable, as there is always a better way to design things
Don’t Wanna Change Your Repository ? Meet the Last Design Pattern You Will Ever Need
Bloated Repositories are not Inevitable, as there is always a better way to design things
At first, the Repository Pattern feels clean and manageable. Your ItemRepository only contains a couple of methods: GetAllAsync() and GetByIdAsync().
Simple.
But applications rarely stay simple for long.
You need to get items by category. Then by tag. Then sorted by price. Then filtered by category and tag, sorted in descending order. That’s a lot of criteria’s coming in one by one!!
👉 If you are a non member — Access this story for free.

Before you know it your repository has 15 methods. Every new filter is a new method. Every new sort is a new overload.
This is the generally the problem that Specification Pattern solves and when you combine it with a Generic Repository, your repository stops growing entirely. Surprising right!!
To uncover this, we are taking an example of an item from an e-commerce application to see how can we combine these 2 patterns once and for all to stop the endless code growth.
The Problem With a Typical Repository
Most repository implementations end up looking like this:
Task<IReadOnlyList<Item>> GetItemsByCategoryAsync(string category);
Task<IReadOnlyList<Item>> GetItemsByTagAsync(string tag);
Task<IReadOnlyList<Item>> GetItemsByCategoryAndTagAsync(string category, string tag);
Task<IReadOnlyList<Item>> GetItemsSortedByPriceAsync();
Every new requirement adds a new method. The repository becomes a dumping ground for query logic.
The deeper problem is that this logic is untestable, hard to reuse and tightly coupled to your data layer.
So, is there a fix to it? You probably know it as the* Specification Pattern, *but it is enough or do we need to couple it with something else as well?
Read Along to find out!
What Is the Specification Pattern?
A specification is simply a class that encapsulates a query which is the filter, the ordering, the projection all in one place.
Instead of writing query logic inside your repository, you write it inside a specification and pass it in.
The repository does not care what the query is. It just applies whatever specification it receives. This can be a high level explanation for understanding the specification but for defining it we need need to define what a spec can do
The Specification Interface
First we define what a specification looks like:
public interface ISpecification<T>
{
Expression<Func<T, bool>>? Criteria { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDesc { get; }
bool IsDistinct { get; }
}
public interface ISpecification<T, TResult> : ISpecification<T>
{
Expression<Func<T, TResult>>? Select { get; }
}
Criteria is your where clause. OrderBy and OrderByDescending handle sorting. Select handles projection, returning a different shape than the entity itself.
All of these are expressions, which means EF Core can translate them directly into SQL and we just will need to build on the query. Now as the interface is defined we just need a reusable building block which is a base implementation of this.
The Base Class
The interface defines the contract. The base class gives you the tools to implement it:
public class BaseSpecification<T>(Expression<Func<T, bool>>? criteria) : ISpecification<T>
{
protected BaseSpecification() : this(null) { }
public Expression<Func<T, bool>>? Criteria => criteria;
public Expression<Func<T, object>>? OrderBy { get; private set; }
public Expression<Func<T, object>>? OrderByDesc { get; private set; }
public bool IsDistinct { get; private set; }
protected void AddOrderBy(Expression<Func<T, object>> orderByExpression)
{
OrderBy = orderByExpression;
}
protected void AddOrderByDescending(Expression<Func<T, object>> orderByDescExpression)
{
OrderByDesc = orderByDescExpression;
}
protected void ApplyDistinct()
{
IsDistinct = true;
}
}
A couple of things are worth noting here.
The class uses the primary constructor syntax.
The criteria parameter is passed directly in the class declaration instead of inside a traditional constructor body, which keeps the code concise and easier to read.
The protected BaseSpecification() : this(null) is a parameterless constructor that chains to the primary constructor with null.
This is useful when you want a specification that fetches everything without applying any filter. You can simply use the blank constructor and skip the criteria entirely.
The AddOrderBy, AddOrderByDescending and ApplyDistinct methods are marked as protected, which means only classes inheriting from BaseSpecification can access them.
Child specifications can use these helper methods to configure query behavior without directly modifying the underlying properties.
What about Projection ?
There is a second variant for cases where you don’t want to return the full entity but a different shape, like returning just the name and price instead of the entire object:
public class BaseSpecification<T, TResult>(Expression<Func<T, bool>>? criteria)
: BaseSpecification<T>(criteria), ISpecification<T, TResult>
{
protected BaseSpecification() : this(null) { }
public Expression<Func<T, TResult>>? Select { get; private set; }
protected void AddSelect(Expression<Func<T, TResult>> selectExpression)
{
Select = selectExpression;
}
}
BaseSpecification<T, TResult> inherits everything from the base and adds a Select expression on top. When the evaluator sees a Select defined, it applies it to the query and returns the projected type instead of the full entity.
Having done this, we still haven’t uncovered the real hidden power !
The Dynamic Specification
This is where most articles stop at a basic example. But real applications have dynamic queries, filters that change based on what the user sends in.
Here is an ItemSpecification that handles optional category filtering, optional tag filtering and dynamic sorting, all in one place:
public class ItemSpecification(string? category, string? tag, string? sort)
: BaseSpecification<Item>(x =>
(string.IsNullOrWhiteSpace(category) || x.Category == category) &&
(string.IsNullOrWhiteSpace(tag) || x.Tag == tag))
{
switch (sort)
{
case "asc":
AddOrderBy(x => x.Price);
break;
case "desc":
AddOrderByDescending(x => x.Price);
break;
default:
AddOrderBy(x => x.Name);
break;
}
}
Notice what’s happening with the filter.
It uses short-circuit evaluation.
If category is null or empty, that condition is skipped entirely, so all items pass through regardless of category.
The same logic applies to tag.
This allows a single specification to handle every possible combination:
- No category
- No tag
- Both category and tag
- Either one individually
The sorting is handled through a switch statement that calls the appropriate helper method from the base class.
No scattered if/else chains inside services, No 10 different repository methods.
Just one specification that cleanly handles all cases. All we need now is to gap the bridge between spec and the query.
The Specification Evaluator
The evaluator is what actually applies the specification to an IQueryable. It sits between your repository and EF Core:
public class SpecificationEvaluator<T> where T : BaseEntity
{
public static IQueryable<T> GetQuery(IQueryable<T> query, ISpecification<T> spec)
{
if (spec.Criteria != null)
query = query.Where(spec.Criteria);
if (spec.OrderBy != null)
query = query.OrderBy(spec.OrderBy);
if (spec.OrderByDesc != null)
query = query.OrderByDescending(spec.OrderByDescending);
if (spec.IsDistinct)
query = query.Distinct();
return query;
}
}
Each condition is checked independently. Only what the specification defines gets applied — nothing more, nothing less.
An important thing to understand here is that nothing is executed yet at this point. EF Core is building an IQueryable which is a description of the query. The actual SQL is only generated and sent to the database when you call something like ToListAsync() at the end. This means the entire specification is translated into a single optimised SQL query, not filtered in memory.
And finally we now come on to the generic repository, the one stop for all different entities.
The Generic Repository
With the evaluator in place, the generic repository stays clean and never needs to change:
public interface IGenericRepository<T> where T : BaseEntity
{
Task<T?> GetById(int id); // for a single item with a particular id
Task<IReadOnlyList<T>> ListAllAsync(); // to get all items of a particular type
Task<T?> GetEntityWithSpec(ISpecification<T> spec); // for getting the entity with a particular spec
Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec); // for getting a list of entities with a particular spec
Task<TResult?> GetEntityWithSpec<TResult>(ISpecification<T, TResult> spec); // for getting a entity with a diff. return type with a spec
Task<IReadOnlyList<TResult>> ListAsync<TResult>(ISpecification<T, TResult> spec); // for getting a list of entities with a diff return type with a spec
void Add(T entity); // normal operations to add, update, delete
void Update(T entity);
void Delete(T entity);
Task<bool> SaveAsync();
}
The implementation is the following:
public class GenericRepository<T>(StoreContext _context) : IGenericRepository<T> where T : BaseEntity
{
public void Add(T entity)
{
_context.Set<T>().Add(entity);
}
public async Task<T?> GetById(int id)
{
return await _context.Set<T>().FindAsync(id);
}
public async Task<T?> GetEntityWithSpec(ISpecification<T> spec)
{
return await ApplySpecs(spec).FirstOrDefaultAsync();
}
public async Task<TResult?> GetEntityWithSpec<TResult>(ISpecification<T, TResult> spec)
{
return await ApplySpecs(spec).FirstOrDefaultAsync();
}
public async Task<IReadOnlyList<T>> ListAllAsync()
{
return await _context.Set<T>().ToListAsync();
}
public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
{
return await ApplySpecs(spec).ToListAsync();
}
public async Task<IReadOnlyList<TResult>> ListAsync<TResult>(ISpecification<T, TResult> spec)
{
return await ApplySpecs(spec).ToListAsync(); // it is automatically implied which method is to be used
}
public void Remove(T entity)
{
_context.Set<T>().Remove(entity);
}
public async Task<bool> SaveAsync()
{
return await _context.SaveChangesAsync() > 0;
}
public void Update(T entity)
{
_context.Set<T>().Attach(entity);
_context.Entry(entity).State = EntityState.Modified;
}
private IQueryable<T> ApplySpecs(ISpecification<T> spec)
{
return SpecificationEvaluator<T>.GetQuery(_context.Set<T>().AsQueryable(), spec);
}
private IQueryable<TResult> ApplySpecs<TResult>(ISpecification<T, TResult> spec)
{
return SpecificationEvaluator<T>.GetQuery<T, TResult>(_context.Set<T>().AsQueryable(), spec);
}
}
}
No query logic. No if/else. The repository’s only job is to pass the specification to the evaluator and return the result.
Notice also the where T : BaseEntity constraint on the interface. This ensures the generic repository only works with actual entity classes and not arbitrary types. It is a small guard that keeps the pattern from being misused.
How It All Comes Together
When a request comes in to get items filtered by category, sorted by price:
var spec = new ItemSpecification(category: "Shoes", tag: null, sort: "asc");
var items = await _repo.ListAsync(spec);
The specification builds the query. The evaluator applies the query. The repository simply returns the result.
Now imagine a new requirement comes in tomorrow — for example, filtering items by price range.
You can either create a new specification or extend the existing one. The repository does not change. The evaluator does not change. Nothing else in the system needs to change.
That is the real strength of this combination, the query logic grows without bloating the repository layer. A Visual representation of this system will look something like this

Here, very controller will have a specification of concrete specification which will implement the base specification class, After the Database returns the data the controller sends the data via the appropriate endpoint.
Closing Thoughts
The thing that stands out most is not how clever it is but how calm the codebase feels
New requirements stop being a reason to touch the repository. Query logic has a home and because everything is an expression, EF Core is still translating it all into optimized SQL.
That is the real win. Not just cleaner code but a codebase that is easy to reason about when things change.
메타데이터
- post_id
- 144cc6c6a127
- slug
- dont-wanna-change-your-repository-meet-the-last-design-pattern-you-will-ever-need-144cc6c6a127
- url
- https://medium.com/c-sharp-programming/dont-wanna-change-your-repository-meet-the-last-design-pattern-you-will-ever-need-144cc6c6a127
- canonical_url
- https://medium.com/c-sharp-programming/dont-wanna-change-your-repository-meet-the-last-design-pattern-you-will-ever-need-144cc6c6a127
- author_url
- https://medium.com/@kroshpan
- status
- ok
- fetched_at
- 2026-06-22 12:55:45