Repository pattern vs. Specification pattern: Which is more maintainable?
When talking about Domain-Driven Design (DDD), the repository pattern becomes a default choice for the data access layer. Another pattern is the Specification pattern that organizes code into smaller objects. The question raised here is which suits your project and which is more maintainable as your project scales? I will unpack both patterns today and determine which is more maintainable.

What is the Repository Pattern?
The repository pattern is a design pattern that separates the data access layer from the business logic layer. It plays a key role in Domain-Driven Design (DDD), which assumes that data resides in memory. Most developers opt for the Repository pattern as the default for accessing data from sources such as relational databases or files. It is so popular that EF Core ORM itself uses the Repository pattern. Let's define some prominent terms used in the repository pattern.
Entity
An entity represents a real-world concept with a unique identity. An entity can change state, but its unique identity persists over time. Customer, Order, Building, Student, and Course are examples of an entity.
public class Customer
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Email { get; private set; }
}public class Building
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Address { get; private set; }
}Customer's Name and Email can be updated, but the Id will remain constant as it is the primary key and unique identifier of a customer. Similarly, Name and Address can be updated by different operations, but Id will not change.
Aggregate
An Aggregate is a cluster of related Entities and Value Objects that are treated as a single unit for data changes. Every aggregate has a special Entity called the Aggregate Root that serves as the entry point of the aggregate. The repository handles the aggregate root entity, and the root entity controls access to and modification of its internal entities.
An Aggregate encapsulates a lot of inner details. Like a building encapsulates all its floors and locations (rooms, washrooms, balconies, hallways, etc.). You can refer to 'your building'. The building is the aggregate root, and behind it are the floors, rooms, halls, etc. Consider the coding example to better understand it.
Domain entity
// Aggregate Root
public class Building
{
public int Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Address { get; private set; } = string.Empty;
private readonly List<Floor> _floors = new();
public IReadOnlyCollection<Floor> Floors => _floors.AsReadOnly();
protected Building() { } // For EF Core
public Building(string name, string address)
{
Name = name;
Address = address;
}
public Floor AddFloor(string name)
{
var floor = new Floor(name, Id);
_floors.Add(floor);
return floor;
}
}
Child entity of the floor
public class Floor
{
public int Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public int BuildingId { get; private set; }
private readonly List<Location> _locations = new();
public IReadOnlyCollection<Location> Locations => _locations.AsReadOnly();
protected Floor() { }
public Floor(string name, int buildingId)
{
Name = name;
BuildingId = buildingId;
}
public Location AddLocation(string name)
{
var location = new Location(name, Id);
_locations.Add(location);
return location;
}
}
Child entity of location
public class Location
{
public int Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public int FloorId { get; private set; }
protected Location() { }
public Location(string name, int floorId)
{
Name = name;
FloorId = floorId;
}
}
The building is the aggregate root, while floors and locations are contained entities. The repository will load and persist the entire Building aggregate (with floors and locations). You can apply Lazy loading to load entities more efficiently.
Generic Repository
A generic repository is a repository pattern that defines a reusable base repository for all entities, supporting common CRUD operations,. It allows a handy approach to extend the codebase without repeating common operation code.
Step 1: Define models
public class Building
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Navigation property (aggregate relationship)
public List<Floor> Floors { get; set; } = new();
}
public class Floor
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int BuildingId { get; set; }
public Building Building { get; set; } = null!;
}
Step 2: Configure database context
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Building> Buildings => Set<Building>();
public DbSet<Floor> Floors => Set<Floor>();
}
Step 3: Generic repository interface
public interface IGenericRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
}
Step 4: Generic repository implementation
Here we implement this interface once for all entities.
using Microsoft.EntityFrameworkCore;
public class GenericRepository<T> : IGenericRepository<T> where T : class
{
private readonly AppDbContext _context;
private readonly DbSet<T> _dbSet;
public GenericRepository(AppDbContext context)
{
_context = context;
_dbSet = _context.Set<T>();
}
public async Task<IEnumerable<T>> GetAllAsync()
{
return await _dbSet.ToListAsync();
}
public async Task<T?> GetByIdAsync(int id)
{
return await _dbSet.FindAsync(id);
}
public async Task AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
}
public void Update(T entity)
{
_dbSet.Update(entity);
}
public void Delete(T entity)
{
_dbSet.Remove(entity);
}
public async Task SaveAsync()
{
await _context.SaveChangesAsync();
}
}
Step 5: Use the generic repository in the service layer
Now, using a generic repository for our building and floor entities
public class BuildingService
{
private readonly IGenericRepository<Building> _buildingRepo;
private readonly IGenericRepository<Floor> _floorRepo;
public BuildingService(
IGenericRepository<Building> buildingRepo,
IGenericRepository<Floor> floorRepo)
{
_buildingRepo = buildingRepo;
_floorRepo = floorRepo;
}
public async Task AddBuildingWithFloorsAsync()
{
var building = new Building
{
Name = "Main Office",
Floors = new List<Floor>
{
new Floor { Name = "Ground Floor" },
new Floor { Name = "First Floor" }
}
};
await _buildingRepo.AddAsync(building);
await _buildingRepo.SaveAsync(); // Save once for simplicity
}
public async Task<IEnumerable<Building>> GetAllBuildingsAsync()
{
return await _buildingRepo.GetAllAsync();
}
}
Issues with the generic repository
A generic repository compacts the repeated code. However, it has a downside: it exposes all methods to every user. For example, if you want to restrict different users from performing various operations. In that case, suppose only admins are authorized to create, update, and delete, while ordinary users can only read the data. A generic repository gives everyone access to all operations. It imposes no restrictions on entities, meaning any user can perform delete operations on critical entities, such as User or Department. Besides, with access control, a generic repository opens the risk of writing inefficient queries that fetch unwanted data and introduce performance-critical methods.
Specification repository
To address access control issues in a generic repository, we use another repository pattern: the Specification repository. A Specification repository encapsulates query logic (filtering, conditions, includes, sorting, paging, etc.) into reusable, combinable objects called Specifications.
In continuation of our previous code, let's add on after the AppDbContext
Step 3: Create the specification pattern core
ISpecification<T> interface
using System;
using System.Linq.Expressions;
public interface ISpecification<T>
{
Expression<Func<T, bool>>? Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
}
Here, I described a Specification that encapsulates a single rule or query condition. Instead of writing Where(x =>) class everywhere, it allows for the reuse of the Criteria expression for filtering conditions. The Includes collection holds navigation property selectors that specify related navigation properties to eager-load (Include).
BaseSpecification<T> to implement the ISpecification interface.
using System.Linq.Expressions;
public abstract class BaseSpecification<T> : ISpecification<T>
{
public Expression<Func<T, bool>>? Criteria { get; }
public List<Expression<Func<T, object>>> Includes { get; } = new();
protected BaseSpecification() { }
protected BaseSpecification(Expression<Func<T, bool>> criteria)
{
Criteria = criteria;
}
protected void AddInclude(Expression<Func<T, object>> includeExpression)
{
Includes.Add(includeExpression);
}
}
Here, we define a base (abstract) class, BaseSpecification, that implements the ISpecification<T> interface. The class contains common logic and utility methods for all specifications. Any concrete specification for an entity doesn't require method logic. The Criteria property is set in the constructor, so the inherited classes can set it once inside their own constructors. However, you can skip filtering when using it if you only need includes. The AddInclude method allows adding multiple Includes to allow child classes. The AddInclude also avoids a possible N+1 issue in the specification by adding eager loading.
SpecificationEvaluator class
using Microsoft.EntityFrameworkCore;
public static class SpecificationEvaluator<T> where T : class
{
public static IQueryable<T> GetQuery(IQueryable<T> inputQuery, ISpecification<T> spec)
{
var query = inputQuery;
if (spec.Criteria != null)
query = query.Where(spec.Criteria);
query = spec.Includes.Aggregate(query, (current, include) => current.Include(include));
return query;
}
}
Step 4: Generic repository using specification
public interface IRepository<T> where T : class
{
Task<IEnumerable<T>> GetAllAsync();
Task<IEnumerable<T>> GetAllAsync(ISpecification<T> spec);
}
The implementation of IRepository
public class Repository<T> : IRepository<T> where T : class
{
private readonly AppDbContext _context;
public Repository(AppDbContext context) => _context = context;
public async Task<IEnumerable<T>> GetAllAsync() => await _context.Set<T>().ToListAsync();
public async Task<IEnumerable<T>> GetAllAsync(ISpecification<T> spec)
{
var query = SpecificationEvaluator<T>.GetQuery(_context.Set<T>(), spec);
return await query.ToListAsync();
}
}
Step 5: Add example specifications
First implementation
public class BuildingsWithFloorsSpec : BaseSpecification<Building>
{
public BuildingsWithFloorsSpec() => AddInclude(b => b.Floors);
}Second implementation
public class BuildingsWithMinFloorsSpec : BaseSpecification<Building>
{
public BuildingsWithMinFloorsSpec(int minFloors)
: base(b => b.Floors.Count >= minFloors)
{
AddInclude(b => b.Floors);
}
}Step 6: Service Layer
public class BuildingService
{
private readonly IRepository<Building> _repository;
public BuildingService(IRepository<Building> repository)
{
_repository = repository;
}
public async Task<IEnumerable<Building>> GetBuildingsWithFloorsAsync()
{
var spec = new BuildingsWithFloorsSpec();
return await _repository.GetAllAsync(spec);
}
public async Task<IEnumerable<Building>> GetBuildingsWithAtLeast3FloorsAsync()
{
var spec = new BuildingsWithMinFloorsSpec(3);
return await _repository.GetAllAsync(spec);
}
}
With the specification, you can add entity-specific logic and control access to operations at a granular level.
Unit Of Work
While working with the repository pattern, we often use another pattern, the Unit of Work (UOW). A unit of work ensures the atomicity of operations by coordinating across multiple repositories. It updates the database at last after all repositories complete their operations. UOW ensures data consistency: either all changes from repositories are saved, or none are reflected if something goes wrong.
Simplify it with actual implementation. Continue the first 2 steps from the preceding examples.
Step 3: Define repository interface
public interface IBuildingRepository
{
Task<Building?> GetByIdAsync(int id);
Task<IEnumerable<Building>> GetAllAsync();
Task AddAsync(Building building);
void Update(Building building);
void Delete(Building building);
}
Floor repository interface
public interface IFloorRepository
{
Task<Floor?> GetByIdAsync(int id);
Task<IEnumerable<Floor>> GetByBuildingIdAsync(int buildingId);
Task AddAsync(Floor floor);
void Delete(Floor floor);
}Step 4: Implement entity repositories
public class BuildingRepository : IBuildingRepository
{
private readonly AppDbContext _context;
public BuildingRepository(AppDbContext context)
{
_context = context;
}
public async Task<Building?> GetByIdAsync(int id)
=> await _context.Buildings.Include(b => b.Floors)
.FirstOrDefaultAsync(b => b.Id == id);
public async Task<IEnumerable<Building>> GetAllAsync()
=> await _context.Buildings.ToListAsync();
public async Task AddAsync(Building building)
=> await _context.Buildings.AddAsync(building);
public void Update(Building building)
=> _context.Buildings.Update(building);
public void Delete(Building building)
=> _context.Buildings.Remove(building);
}
Floor repository
public class FloorRepository : IFloorRepository
{
private readonly AppDbContext _context;
public FloorRepository(AppDbContext context)
{
_context = context;
}
public async Task<Floor?> GetByIdAsync(int id)
=> await _context.Floors.FindAsync(id);
public async Task<IEnumerable<Floor>> GetByBuildingIdAsync(int buildingId)
=> await _context.Floors.Where(f => f.BuildingId == buildingId).ToListAsync();
public async Task AddAsync(Floor floor)
=> await _context.Floors.AddAsync(floor);
public void Delete(Floor floor)
=> _context.Floors.Remove(floor);
}
Step 6: Add the Unit of Work interface
public interface IUnitOfWork
{
IBuildingRepository Buildings { get; }
IFloorRepository Floors { get; }
Task<int> SaveChangesAsync();
}
Step 7: Implement the Unit of Work
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public IBuildingRepository Buildings { get; }
public IFloorRepository Floors { get; }
public UnitOfWork(AppDbContext context)
{
_context = context;
Buildings = new BuildingRepository(_context);
Floors = new FloorRepository(_context);
}
public async Task<int> SaveChangesAsync()
{
return await _context.SaveChangesAsync();
}
}
In the Unit of Work class, I have injected AppDbContext along with two repositories. The initialized repositories are exposed as properties. The most important method here is SaveChangesAsync, which calls EF Core's SaveChangesAsync method. The method actually provides atomic behaviour to the UOW. After all operations are completed in the repositories, we will use the SaveChangesAsync method to apply all changes to the database in a single operation.
Step 8: Usage of the Unit of Work
Let's add a business logic layer in the BuildingService class
public class BuildingService
{
private readonly IUnitOfWork _unitOfWork;
public BuildingService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task AddBuildingAndModifyFloorsAsync()
{
var newBuilding = new Building
{
Name = "Head Office",
Floors =
{
new Floor { Name = "Ground Floor" },
new Floor { Name = "First Floor" }
}
};
await _unitOfWork.Buildings.AddAsync(newBuilding);
var existingBuilding = await _unitOfWork.Buildings.GetByIdAsync(2); // assume building #2 exists
if (existingBuilding != null)
{
var newFloor = new Floor
{
Name = "Third Floor",
BuildingId = existingBuilding.Id
};
await _unitOfWork.Floors.AddAsync(newFloor);
}
var floorToUpdate = await _unitOfWork.Floors.GetByIdAsync(3); // assume floor #3 exists
if (floorToUpdate != null)
{
floorToUpdate.Name = "Updated Floor Name";
}
//Finally — commit all changes together
await _unitOfWork.SaveChangesAsync();
}
}
The method AddBuildingAndModifyFloorsAsync performs the following operations within it using both repositories.
- Add a new building (with two floors)
- Add a new floor to another existing building
- Update an existing floor
Here we performed add operations and committed all at once with _unitOfWork.SaveChangesAsync. If any operation threw an error, all the previous operations will not be committed.
Repository Pattern vs. Specification Pattern: Which Is More Maintainable?
Now comes the actual question of who won the maintainability war? Let's unpack each of them.
Advantages of the Repository Pattern
- Usually have centralized data access logic in a class.
- Encapsulates ORM logic such as Dapper and Entity Framework
- Separates the business layer from accessing data.
Disadvantages of the Repository Pattern
- May require duplicating logic when not using a generic repository.
- Leads to more code as queries grow.
Advantages of the Specification Pattern
- Fulfils the DRY principle as one repository fits all entities.
- Allows reusable, composable, and testable query logic.
- Enables creating a separate repository for different operations and provides Role-based Access Control (RBAC).
- Keeps repositories small and expressive
Disadvantages of the Specification Pattern
- Requires more setup, such as defining BaseSpecification, Evaluator, etc.
- Contains complexity that can be difficult for new developers to understand the specifications' logic.
- It can be overkill for simple CRUD applications.
As per the above observations, the Specification pattern provides more reusable, maintainable classes with the same base repo and specs. Any new query does not require reworking existing courses; you can add a new specification class. Also, it contains fewer repos with more declarative logic and dynamically manageable filters.
Conclusion
Repository and specification patterns form the lower layer in a Domain-Driven Design (DDD). While the repository pattern abstracts the data source to give a collection-like in-memory feel, the Specification pattern divides logic into reusable, small classes called specifications. We look in depth at how each of them encapsulates the data access layer from business logic. Each has its advantages: the repository is easy to implement, and the latter provides more hands-on access control. The specification pattern is more granular in its logic and composability, making it more maintainable.
elmah.io: Error logging and Uptime Monitoring for your web apps
This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.
See how we can help you monitor your website for crashes Monitor your website