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, IHasId

This means:

  • TEntity is a generic type parameter
  • the repository can work with many entity classes
  • each entity must:
    • be a reference type (class)
    • implement the IHasId interface

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 the Person table
  • DbSet<Product> represents the Product table

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 TEntity is Person, then dbContext.Set<Person>() is returned
  • if TEntity is Order, then dbContext.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

  • Table contains the entity set
  • FirstOrDefault(...) searches the first matching row
  • if no row matches, null is 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

  • Table represents all rows of the entity type
  • ToList() 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>> filter

This 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 condition
  • ToList() 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

  1. Table.Add(newEntity) marks the entity as new
  2. DbContext.SaveChanges() sends the insert command to the database
  3. 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

  1. Table.Update(updatedEntity) marks the entity as modified
  2. DbContext.SaveChanges() writes the changes to the database
  3. 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 row
  • ExecuteDelete() sends a direct delete command to the database
  • the number of deleted rows is returned
  • the method returns true only 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:

  • GetById
  • GetAll
  • Get
  • Add
  • Update
  • Delete

No duplication is needed.


Why Creating New Repositories Is So Easy

To support a new entity, you usually only need:

  1. the entity class itself
  2. the entity to implement IHasId
  3. 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> in Table

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 ID
  • GetAll() loads all entities
  • Get(filter) loads all matching entities based on a condition
  • Add(newEntity) inserts a new entity
  • Update(updatedEntity) updates an entity
  • Delete(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

ProsCons
Clear separation between data access and business logicAdds another abstraction layer
Reduces duplicated CRUD codeCan be unnecessary in very small projects
Makes repositories for new entities very easy to createGeneric repositories may become too limited for complex queries
Improves structure and consistencyEntity Framework already provides similar concepts
Can improve testability and maintainabilityPoorly 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 PersonRepository almost effortless to create

For teaching, small to medium projects, and applications with a layered architecture, this is a very helpful pattern.