The Repository Pattern in C#
Introduction
The repository pattern is a design pattern that creates a clear separation between the data access layer and the rest of the application.
Instead of letting services, controllers, or UI code work directly with Entity Framework and the database context, a repository acts as an intermediate layer responsible for loading, storing, updating, and deleting entities.
In practice, this means:
- the application asks the repository for data
- the repository talks to the database
- the rest of the application does not need to know how the data is stored
A repository usually works with one entity type, for example Person, Product, or Order.
Why the Repository Pattern Is Useful
The repository pattern is useful because it helps make code:
Easier to understand
Database logic is stored in one place instead of being spread across controllers and services.
Easier to reuse
Common operations such as GetById, GetAll, Add, Update, and Delete do not need to be rewritten for every entity.
Easier to maintain
If the way data is accessed changes, the change is usually limited to the repository instead of affecting the whole application.
Easier to test
Business logic can depend on repositories instead of directly depending on Entity Framework details.
More structured
The codebase gains a consistent way to work with entities.
The Abstract Repository
Below is the complete implementation of the generic repository:
public abstract class ARepository<TEntity> where TEntity : class, IHasId
{
protected readonly AppDbContext DbContext;
protected readonly DbSet<TEntity> Table;
protected ARepository(AppDbContext dbContext)
{
DbContext = dbContext;
Table = dbContext.Set<TEntity>();
}
public TEntity? GetById(int id)
{
return Table
.FirstOrDefault(r => r.Id == id);
}
public IEnumerable<TEntity> GetAll()
{
return Table.ToList();
}
public IEnumerable<TEntity> Get(Expression<Func<TEntity, bool>> filter)
{
return Table
.Where(filter)
.ToList();
}
public TEntity Add(TEntity newEntity)
{
Table.Add(newEntity);
DbContext.SaveChanges();
return newEntity;
}
public TEntity Update(TEntity updatedEntity)
{
Table.Update(updatedEntity);
DbContext.SaveChanges();
return updatedEntity;
}
public bool Delete(int id)
{
var nrOfDeletedRows = Table.Where(e => e.Id == id).ExecuteDelete();
return nrOfDeletedRows == 1;
}
}
public interface IHasId
{
public int Id { get; }
}What Makes This Repository Generic?
The repository is declared as:
public class ARepository<TEntity> where TEntity : class, IHasIdThis means:
TEntityis a generic type parameter- the repository can work with many entity classes
- each entity must:
- be a reference type (
class) - implement the
IHasIdinterface
- be a reference type (
Because of that, the repository can safely access entity.Id.
The IHasId Interface
public interface IHasId
{
public int Id { get; }
}This interface requires every supported entity to have an Id property.
That is important because methods like GetById and Delete need a common way to identify entities.
Without IHasId, the generic repository would not know whether a type has an Id.
The Fields of ARepository
Inside the repository there are two protected fields:
protected readonly AppDbContext DbContext;
protected readonly DbSet<TEntity> Table;DbContext
DbContext stores the database context object that is passed into the repository.
It gives access to:
- the database connection
- entity tracking
- saving changes
- all configured entity sets
Table
Table is the DbSet<TEntity> for the current entity type.
A DbSet<TEntity> represents the collection of database rows for one entity type.
For example:
DbSet<Person>represents thePersontableDbSet<Product>represents theProducttable
This field is stored so that all repository methods can directly work with the correct entity set.
How the Constructor Works
The constructor is:
protected ARepository(AppDbContext dbContext)
{
DbContext = dbContext;
Table = dbContext.Set<TEntity>();
}Step 1: Receive the database context
The constructor receives an AppDbContext object from outside.
Usually this happens through dependency injection.
Step 2: Store the context
DbContext = dbContext;The passed context is stored in the field so that all methods in the repository can use it later.
Step 3: Extract the correct DbSet
Table = dbContext.Set<TEntity>();This is a very important line.
Set<TEntity>() asks Entity Framework for the DbSet that belongs to the entity type TEntity.
Examples:
- if
TEntityisPerson, thendbContext.Set<Person>()is returned - if
TEntityisOrder, thendbContext.Set<Order>()is returned
This is what makes the repository reusable.
The same base class automatically works with different entity types.
Why is the constructor protected?
The constructor is protected because the repository is meant to be used as a base class for concrete repositories, such as PersonRepository. Note that ARepository is also marked as abstract, so you can’t create an instance of this base class,
but have to create a derived class first.
You normally do not create ARepository<Person> directly in application code.
Instead, you create a concrete repository such as PersonRepository and let it call the base constructor.
Explanation of the Individual Methods
1. GetById
public TEntity? GetById(int id)
{
return Table
.FirstOrDefault(r => r.Id == id);
}What it does
This method returns the first entity whose Id matches the given id.
Why TEntity?
The method may return null if no matching entity exists.
That is why the return type is nullable.
How it works
Tablecontains the entity setFirstOrDefault(...)searches the first matching row- if no row matches,
nullis returned
Example
var person = personRepository.GetById(5);This loads the person with ID 5, if it exists.
2. GetAll
public IEnumerable<TEntity> GetAll()
{
return Table.ToList();
}What it does
This method loads all entities of the current type from the database.
How it works
Tablerepresents all rows of the entity typeToList()executes the query immediately and returns a list
Example
var persons = personRepository.GetAll();This loads all persons.
Important note
For very large tables, loading everything may be expensive.
In real applications, pagination is often added (this means loading chunks of a table instead of all entries).
3. Get
public IEnumerable<TEntity> Get(Expression<Func<TEntity, bool>> filter)
{
return Table
.Where(filter)
.ToList();
}What it does
This method loads all entities that match a custom filter.
Understanding the parameter
Expression<Func<TEntity, bool>> filterThis means the method receives a condition such as:
p => p.LastName == "Miller"Entity Framework translates this expression into SQL.
How it works
Where(filter)applies the conditionToList()executes the query and returns the matching entities
Example
var adults = personRepository.Get(p => p.Age >= 18);This loads all persons whose age is at least 18.
Why use Expression<Func<...>> instead of Func<...>?
Because Entity Framework can translate an expression tree into SQL.
A normal delegate (Func<...>) would only run in memory after loading data.
4. Add
public TEntity Add(TEntity newEntity)
{
Table.Add(newEntity);
DbContext.SaveChanges();
return newEntity;
}What it does
This method adds a new entity to the database.
How it works
Table.Add(newEntity)marks the entity as newDbContext.SaveChanges()sends the insert command to the database- the inserted entity is returned
Example
var person = new Person { FirstName = "Anna", LastName = "Meyer" };
personRepository.Add(person);Why return the entity?
After saving, the entity may contain generated values such as an auto-incremented ID.
Returning it makes it easy to keep working with the saved object.
5. Update
public TEntity Update(TEntity updatedEntity)
{
Table.Update(updatedEntity);
DbContext.SaveChanges();
return updatedEntity;
}What it does
This method updates an existing entity in the database.
How it works
Table.Update(updatedEntity)marks the entity as modifiedDbContext.SaveChanges()writes the changes to the database- the updated entity is returned
Example
person.LastName = "Huber";
personRepository.Update(person);Important note
This assumes that the entity already exists and has a valid ID.
6. Delete
public bool Delete(int id)
{
var nrOfDeletedRows = Table.Where(e => e.Id == id).ExecuteDelete();
return nrOfDeletedRows == 1;
}What it does
This method deletes the entity with the given ID.
How it works
Where(e => e.Id == id)filters the matching rowExecuteDelete()sends a direct delete command to the database- the number of deleted rows is returned
- the method returns
trueonly if exactly one row was deleted
Why is this useful?
This avoids first loading the entity into memory.
That can be more efficient than:
var entity = GetById(id);
Table.Remove(entity);
DbContext.SaveChanges();Important detail
ExecuteDelete() directly executes SQL on the database and does not require SaveChanges() afterward.
Creating a Repository for a Concrete Entity
One of the biggest advantages of this pattern is how little code is needed for a new entity repository.
Example:
public class PersonRepository : ARepository<Person>
{
public PersonRepository(AppDbContext dbContext) : base(dbContext)
{
}
}This class inherits all common operations from ARepository<Person>.
That means PersonRepository automatically gets:
GetByIdGetAllGetAddUpdateDelete
No duplication is needed.
Why Creating New Repositories Is So Easy
To support a new entity, you usually only need:
- the entity class itself
- the entity to implement
IHasId - a small repository class that inherits from
ARepository<TEntity>
For example, if there is a Product entity, the repository would look almost the same:
public class ProductRepository : ARepository<Product>
{
public ProductRepository(AppDbContext dbContext) : base(dbContext)
{
}
}This is possible because all shared CRUD logic is already stored in the base repository.
Typical Usage Example
A service could use the repository like this:
public class PersonService
{
private readonly PersonRepository _personRepository;
public PersonService(PersonRepository personRepository)
{
_personRepository = personRepository;
}
public IEnumerable<Person> GetAdults()
{
return _personRepository.Get(p => p.Age >= 18);
}
}The service does not need to know the details of DbContext or DbSet.
It simply asks the repository for the data it needs.
Advantages of This Design
This implementation provides:
- one reusable place for standard CRUD operations
- less repeated code
- a simple structure for new repositories
- clear separation between business logic and data access
- a good foundation for adding entity-specific repository methods later
For example, PersonRepository could later add methods such as:
public IEnumerable<Person> GetByLastName(string lastName)
{
return Table.Where(p => p.LastName == lastName).ToList();
}So the base repository handles general behavior, while the concrete repository can add special queries.
Possible Limitations
The repository pattern is useful, but it is not always perfect.
Entity Framework already behaves partly like a repository and unit-of-work system.
Because of that, some developers consider an extra repository layer unnecessary in simple applications.
Also, generic repositories can become too simplistic if an application needs many complex queries.
So the pattern is most useful when:
- the application should follow a clear layered architecture
- CRUD operations are repeated often
- you want a consistent abstraction over data access
- repositories may later contain custom domain-specific queries
Summary
The repository pattern wraps database access in dedicated classes.
In this example, ARepository<TEntity> is a generic base repository that works for every entity implementing IHasId.
It stores:
- the database context in
DbContext - the matching
DbSet<TEntity>inTable
Its constructor receives the AppDbContext and uses dbContext.Set<TEntity>() to automatically get the correct entity set.
The methods provide basic CRUD functionality:
GetById(int id)loads one entity by IDGetAll()loads all entitiesGet(filter)loads all matching entities based on a conditionAdd(newEntity)inserts a new entityUpdate(updatedEntity)updates an entityDelete(id)deletes an entity by ID
A concrete repository such as PersonRepository is very short because it inherits all standard behavior from the base class.
That is the main strength of this design:
shared logic is implemented once and reused for every entity type.
Pros and Cons of Using a Repository
| Pros | Cons |
|---|---|
| Clear separation between data access and business logic | Adds another abstraction layer |
| Reduces duplicated CRUD code | Can be unnecessary in very small projects |
| Makes repositories for new entities very easy to create | Generic repositories may become too limited for complex queries |
| Improves structure and consistency | Entity Framework already provides similar concepts |
| Can improve testability and maintainability | Poorly designed repositories can hide useful EF features |
Final Conclusion
The repository pattern is a practical way to organize database access in a clean and reusable form.
Your ARepository<TEntity> implementation is a good example of a simple generic repository:
- it is easy to understand
- it avoids repeated CRUD code
- it makes concrete repositories like
PersonRepositoryalmost effortless to create
For teaching, small to medium projects, and applications with a layered architecture, this is a very helpful pattern.