The classic repository pattern is a simple way to implement database access in any application. There are ways to improve it.
Introduction
The classic repository pattern is a simple way to implement database access in any application. It meets many of the normal design goals for a small application. On the other side, CQS and CQRS provide a more complex but well structured design pattern for larger more complex applications.
In this article, I'll develop the basic repository pattern applying some of the fundamentally good practices used in CQS and implement a fully generic provider.
This is not a regurgitated IRepository
implementation in DotNetCore with a few frills.
- There's no implementation per entity class. You won't see this:
public class WeatherForecastRepository : GenericRepository<WeatherForecast>,
IWeatherForcastRepository
{
public WeatherForecastRepository(DbContextClass dbContext) : base(dbContext) {}
}
public interface IProductRepository : IGenericRepository<WeatherForecast> { }
- There's no separate
UnitOfWork
classes: it's built in. - All standard Data I/O uses a single Data Broker.
- CQS Requests, Results and Handler patterns are used in the design.
Nomenclature, Terminology and Practices
- DI: Dependency Injection
- CQS: Command/Query Separation
The code is:
- Net7.0
- C# 10
- Nullable enabled
Repo
The Repo and latest version of this article are here: Blazr.IRepository.
The Data Store
The solution needs a real data store for testing: it implements an Entity Framework In-Memory database.
I'm a Blazor developer so my test data class is WeatherForecast
. The code for the data provider is in the Appendix.
This is the DbContext
used by the DBContext
factory.
public sealed class InMemoryWeatherDbContext : DbContext
{
public DbSet<WeatherForecast> WeatherForecast { get; set; } = default!;
public InMemoryWeatherDbContext
(DbContextOptions<InMemoryWeatherDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<WeatherForecast>().ToTable("WeatherForecast");
}
Testing the Factory and Context
The following XUnit
test demonstrates the basic datastore setup in DI. It:
- sets up a DI container.
- loads the data from the Test Provider.
- tests the record count is correct.
- tests one arbitrary record is correct.
[Fact]
public async Task DBContextTest()
{
var testProvider = WeatherTestDataProvider.Instance();
var services = new ServiceCollection();
services.AddDbContextFactory<InMemoryWeatherDbContext>(options
=> options.UseInMemoryDatabase($"WeatherDatabase-{Guid.NewGuid().ToString()}"));
var rootProvider = services.BuildServiceProvider();
var providerScope = rootProvider.CreateScope();
var provider = providerScope.ServiceProvider;
var factory = provider.GetService<IDbContextFactory<InMemoryWeatherDbContext>>();
if (factory is not null)
WeatherTestDataProvider.Instance().LoadDbContext
<InMemoryWeatherDbContext>(factory);
var dbContext = factory!.CreateDbContext();
Assert.NotNull(dbContext);
var count = dbContext.Set<WeatherForecast>().Count();
Assert.Equal(testProvider.WeatherForecasts.Count(), count);
var testRecord = testProvider.GetRandomRecord()!;
var record = await dbContext.Set<WeatherForecast>().SingleOrDefaultAsync
(item => item.Uid.Equals(testRecord.Uid));
Assert.Equal(testRecord, record);
providerScope.Dispose();
rootProvider.Dispose();
}
The Classic Repository Pattern Implementation
Here's a nice succinct implementation that I found on the Internet.
public abstract class Repository<T> : IRepository<T> where T : class
{
protected readonly DbContextClass _dbContext;
protected GenericRepository(DbContextClass context)
=> _dbContext = context;
public async Task<T> GetById(int id)
=> await _dbContext.Set<T>().FindAsync(id);
public async Task<IEnumerable<T>> GetAll()
=> await _dbContext.Set<T>().ToListAsync();
public async Task Add(T entity)
=> await _dbContext.Set<T>().AddAsync(entity);
public void Delete(T entity)
=> _dbContext.Set<T>().Remove(entity);
public void Update(T entity)
=> _dbContext.Set<T>().Update(entity);
}
}
Picking it apart:
- What happens when a
null
is returned, what does it mean? - Did that
add
/update
/delete
really succeed? How do I know? - How do you handle cancellation tokens? Most
async
methods now accept a cancellation token. - What happens when your
DBSet
contains a million records (maybe the DBA got something wrong last night)? - There's one of me for every data store entity in the application.
The Implementation
Requests and Results
Request objects encapsulate what we request and result objects the data and status information we expect back. They are records
: defined once and then consumed.
Commands
A Command is a request to make a change to the data store: Create
/Update
/Delete
operations. We can define one like this:
public record CommandRequest<TRecord>
{
public required TRecord Item { get; init; }
public CancellationToken Cancellation { get; set; } = new ();
}
Commands only return status information: no data. We can define a result like this:
public record CommandResult
{
public bool Successful { get; init; }
public string Message { get; init; } = string.Empty;
private CommandResult() { }
public static CommandResult Success(string? message = null)
=> new CommandResult { Successful = true, Message= message ?? string.Empty };
public static CommandResult Failure(string message)
=> new CommandResult { Message = message};
}
At this point, it's worth noting one small exception to the return rule: the Id
for an inserted record. If you aren't using Guid
s to give your records unique identifiers, then the database generated Id
is status information.
Item Requests
A Query is a request to get data from the data store: no mutation. We can define an item query like this:
public sealed record ItemQueryRequest
{
public required Guid Uid { get; init; }
public CancellationToken Cancellation { get; set; } = new();
}
And the return result: the requested data and status.
public sealed record ItemQueryResult<TRecord>
{
public TRecord? Item { get; init;}
public bool Successful { get; init; }
public string Message { get; init; } = string.Empty;
private ItemQueryResult() { }
public static ItemQueryResult<TRecord>
Success(TRecord Item, string? message = null)
=> new ItemQueryResult<TRecord>
{ Successful=true, Item= Item, Message= message ?? string.Empty };
public static ItemQueryResult<TRecord> Failure(string message)
=> new ItemQueryResult<TRecord> { Message = message};
}
List Queries
List queries present a few extra challenges:
- They should never request everything. In edge conditions, there may be 1,000,000+ rows in a table. Every request should be constrained. The request defines
StartIndex
and PageSize
to both constrain the data and provide paging. If you set the page size to 1,000,000, will your data pipeline and front end handle it gracefully? - They need to handle sorting and filtering. The request defines these as Linq Expressions.
public sealed record ListQueryRequest<TRecord>
{
public int StartIndex { get; init; } = 0;
public int PageSize { get; init; } = 1000;
public CancellationToken Cancellation { get; set; } = new ();
public bool SortDescending { get; } = false;
public Expression<Func<TRecord, bool>>? FilterExpression { get; init; }
public Expression<Func<TRecord, object>>? SortExpression { get; init; }
}
The result returns the items, the total item count (for paging) and status information. Items
are always returned as IEnumerable
.
public sealed record ListQueryResult<TRecord>
{
public IEnumerable<TRecord> Items { get; init;} = Enumerable.Empty<TRecord>();
public bool Successful { get; init; }
public string Message { get; init; } = string.Empty;
public long TotalCount { get; init; }
private ListQueryResult() { }
public static ListQueryResult<TRecord> Success
(IEnumerable<TRecord> Items, long totalCount, string? message = null)
=> new ListQueryResult<TRecord> {Successful=true, Items= Items,
TotalCount = totalCount, Message= message ?? string.Empty };
public static ListQueryResult<TRecord> Failure(string message)
=> new ListQueryResult<TRecord> { Message = message};
}
Handlers
Handlers are small single purpose classes that handle requests and return results. They abstract the nitty-gritty execution from the higher level Data Broker.
Command Handlers
The interface provides the abstraction.
public interface ICreateRequestHandler
{
public ValueTask<CommandResult> ExecuteAsync<TRecord>
(CommandRequest<TRecord> request)
where TRecord : class, new();
}
And the implementation does the real work.
- Injects the
DBContext
Factory. - Implements Unit of Work Db contexts through the
DbContext
factory. - Uses the
Add
method on the context to add the record to EF. - Calls
SaveChangesAsync
, passing in the Cancellation token, and expects a single change to be reported. - Provides status information if things go wrong.
public sealed class CreateRequestHandler<TDbContext>
: ICreateRequestHandler
where TDbContext : DbContext
{
private readonly IDbContextFactory<TDbContext> _factory;
public CreateRequestHandler(IDbContextFactory<TDbContext> factory)
=> _factory = factory;
public async ValueTask<CommandResult> ExecuteAsync<TRecord>
(CommandRequest<TRecord> request)
where TRecord : class, new()
{
if (request == null)
throw new DataPipelineException
($"No CommandRequest defined in {this.GetType().FullName}");
using var dbContext = _factory.CreateDbContext();
dbContext.Add<TRecord>(request.Item);
return await dbContext.SaveChangesAsync(request.Cancellation) == 1
? CommandResult.Success("Record Updated")
: CommandResult.Failure("Error updating Record");
}
}
The Update
and Delete
handlers are the same but use different dbContext
methods: Update
and Remove
.
Item Request Handler
The interface.
public interface IItemRequestHandler
{
public ValueTask<ItemQueryResult<TRecord>> ExecuteAsync<TRecord>
(ItemQueryRequest request)
where TRecord : class, new();
}
And the server implementation. Note:
- Injects the
DBContext
Factory. - Implements Unit of Work Db contexts through the
DbContext
factory. - Turns off tracking. There's no mutation involved in this transaction.
- Checks to see if it can use an
Id
to get the item - the record implements IGuidIdentity
. - If not, tries
FindAsync
which uses the inbuilt Key
methodology to get the record. - Provides status information if things go wrong.
public sealed class ItemRequestHandler<TDbContext>
: IItemRequestHandler
where TDbContext : DbContext
{
private readonly IDbContextFactory<TDbContext> _factory;
public ItemRequestHandler(IDbContextFactory<TDbContext> factory)
=> _factory = factory;
public async ValueTask<ItemQueryResult<TRecord>>
ExecuteAsync<TRecord>(ItemQueryRequest request)
where TRecord : class, new()
{
if (request == null)
throw new DataPipelineException
($"No ListQueryRequest defined in {this.GetType().FullName}");
using var dbContext = _factory.CreateDbContext();
dbContext.ChangeTracker.QueryTrackingBehavior =
QueryTrackingBehavior.NoTracking;
TRecord? record = null;
if ((new TRecord()) is IGuidIdentity)
record = await dbContext.Set<TRecord>().SingleOrDefaultAsync
(item => ((IGuidIdentity)item).Uid == request.Uid, request.Cancellation);
if (record is null)
record = await dbContext.FindAsync<TRecord>(request.Uid);
if (record is null)
return ItemQueryResult<TRecord>.Failure
("No record retrieved");
return ItemQueryResult<TRecord>.Success(record);
}
}
List Request Handler
The interface.
public interface IListRequestHandler
{
public ValueTask<ListQueryResult<TRecord>> ExecuteAsync<TRecord>
(ListQueryRequest<TRecord> request)
where TRecord : class, new();
}
And implementation.
Note there are two internal methods:
_getItemsAsync
gets the items. This builds an IQueryable
object and returns a materialized IEnumerable
. You must execute the query before the factory disposes the DbContext
. _getCountAsync
gets the count of all the records based on the filter.
private async ValueTask<IEnumerable<TRecord>> _getItemsAsync<TRecord>
(ListQueryRequest<TRecord> request)
where TRecord : class, new()
{
if (request == null)
throw new DataPipelineException
($"No ListQueryRequest defined in {this.GetType().FullName}");
using var dbContext = _factory.CreateDbContext();
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
IQueryable<TRecord> query = dbContext.Set<TRecord>();
if (request.FilterExpression is not null)
query = query
.Where(request.FilterExpression)
.AsQueryable();
if (request.SortExpression is not null)
query = request.SortDescending
? query.OrderByDescending(request.SortExpression)
: query.OrderBy(request.SortExpression);
if (request.PageSize > 0)
query = query
.Skip(request.StartIndex)
.Take(request.PageSize);
return query is IAsyncEnumerable<TRecord>
? await query.ToListAsync()
: query.ToList();
}
The Repository Class Replacement
First the interface.
The very important bit is the generic TRecord
definition on each method, not on the interface. This removes the need for entity specific implementations.
public interface IDataBroker
{
public ValueTask<ListQueryResult<TRecord>> GetItemsAsync<TRecord>
(ListQueryRequest<TRecord> request) where TRecord : class, new();
public ValueTask<ItemQueryResult<TRecord>> GetItemAsync<TRecord>
(ItemQueryRequest request) where TRecord : class, new();
public ValueTask<CommandResult> UpdateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new();
public ValueTask<CommandResult> CreateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new();
public ValueTask<CommandResult> DeleteItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new();
}
And the implementation. Each handler is registered in DI and injected into the broker.
public sealed class RepositoryDataBroker : IDataBroker
{
private readonly IListRequestHandler _listRequestHandler;
private readonly IItemRequestHandler _itemRequestHandler;
private readonly IUpdateRequestHandler _updateRequestHandler;
private readonly ICreateRequestHandler _createRequestHandler;
private readonly IDeleteRequestHandler _deleteRequestHandler;
public RepositoryDataBroker(
IListRequestHandler listRequestHandler,
IItemRequestHandler itemRequestHandler,
ICreateRequestHandler createRequestHandler,
IUpdateRequestHandler updateRequestHandler,
IDeleteRequestHandler deleteRequestHandler)
{
_listRequestHandler = listRequestHandler;
_itemRequestHandler = itemRequestHandler;
_createRequestHandler = createRequestHandler;
_updateRequestHandler = updateRequestHandler;
_deleteRequestHandler = deleteRequestHandler;
}
public ValueTask<ItemQueryResult<TRecord>> GetItemAsync<TRecord>
(ItemQueryRequest request) where TRecord : class, new()
=> _itemRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<ListQueryResult<TRecord>> GetItemsAsync<TRecord>
(ListQueryRequest<TRecord> request) where TRecord : class, new()
=> _listRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<CommandResult> CreateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new()
=> _createRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<CommandResult> UpdateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new()
=> _updateRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<CommandResult> DeleteItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new()
=> _deleteRequestHandler.ExecuteAsync<TRecord>(request);
}
Testing the Data Broker
We can now define a set of tests for the data broker. I've included two here. The rest are in the Repo.
First two methods to create our root DI container and populate the database.
private ServiceProvider BuildRootContainer()
{
var services = new ServiceCollection();
services.AddDbContextFactory<InMemoryWeatherDbContext>(options
=> options.UseInMemoryDatabase($"WeatherDatabase-{Guid.NewGuid().ToString()}"));
services.AddScoped<IDataBroker, RepositoryDataBroker>();
services.AddScoped<IListRequestHandler,
ListRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<IItemRequestHandler,
ItemRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<IUpdateRequestHandler,
UpdateRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<ICreateRequestHandler,
CreateRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<IDeleteRequestHandler,
DeleteRequestHandler<InMemoryWeatherDbContext>>();
return services.BuildServiceProvider();
}
private IDbContextFactory<InMemoryWeatherDbContext>
GetPopulatedFactory(IServiceProvider provider)
{
var factory = provider.GetService<IDbContextFactory<InMemoryWeatherDbContext>>();
if (factory is not null)
WeatherTestDataProvider.Instance().LoadDbContext
<InMemoryWeatherDbContext>(factory);
return factory!;
}
The GetItems
test:
[Fact]
public async Task GetItemsTest()
{
var testProvider = WeatherTestDataProvider.Instance();
var rootProvider = this.BuildRootContainer();
var providerScope = rootProvider.CreateScope();
var provider = providerScope.ServiceProvider;
var factory = this.GetPopulatedFactory(provider);
var dbContext = factory!.CreateDbContext();
Assert.NotNull(dbContext);
var databroker = provider.GetRequiredService<IDataBroker>();
var request = new ListQueryRequest<WeatherForecast>();
var result = await databroker.GetItemsAsync<WeatherForecast>(request);
Assert.NotNull(result);
Assert.Equal(testProvider.WeatherForecasts.Count(), result.TotalCount);
providerScope.Dispose();
rootProvider.Dispose();
}
The AddItem
test:
[Fact]
public async Task AddItemTest()
{
var testProvider = WeatherTestDataProvider.Instance();
var rootProvider = this.BuildRootContainer();
var providerScope = rootProvider.CreateScope();
var provider = providerScope.ServiceProvider;
var factory = this.GetPopulatedFactory(provider);
var dbContext = factory!.CreateDbContext();
Assert.NotNull(dbContext);
var databroker = provider.GetRequiredService<IDataBroker>();
var newRecord = new WeatherForecast { Uid = Guid.NewGuid(),
Date = DateOnly.FromDateTime(DateTime.Now),
TemperatureC = 50, Summary = "Add Testing" };
{
var request = new CommandRequest<WeatherForecast>() { Item = newRecord };
var result = await databroker.CreateItemAsync<WeatherForecast>(request);
Assert.NotNull(result);
Assert.True(result.Successful);
}
{
var request = new ItemQueryRequest() { Uid = newRecord.Uid };
var result = await databroker.GetItemAsync<WeatherForecast>(request);
Assert.Equal(newRecord, result.Item);
}
{
var request = new ListQueryRequest<WeatherForecast>();
var result = await databroker.GetItemsAsync<WeatherForecast>(request);
Assert.NotNull(result);
Assert.Equal(testProvider.WeatherForecasts.Count() + 1, result.TotalCount);
}
providerScope.Dispose();
rootProvider.Dispose();
}
Wrapping Up
What I've presented here is a hybrid Repository Pattern. It maintains the Repository Pattern's simplicity, and adds some of the best CQS Pattern features.
Abstracting the nitty-gritty EF and Linq code to individual handlers keeps the classes small, succinct and single purpose.
The single Data Broker simplifies data pipeline configuration for the Core and Presentation domains.
To those who believe that implementing any database pipeline over EF is an anti-pattern, my answer is: I use EF as just another Object Request Broker [ORB]. You can plug this pipeline into Dapper, LinqToDb, ... . I never build core business logic code (data relationships) into my Data/Infrastructure Domain: [personal view] crazy idea.
Appendix
The Data Store
The test system implements an Entity Framework In-Memory database.
I'm a Blazor developer so naturally my demo data class is WeatherForecast
. Here's my data class. Note it is a record for immutability and I've set some arbitrary default values for testing purposes.
public sealed record WeatherForecast : IGuidIdentity
{
[Key] public Guid Uid { get; init; } = Guid.Empty;
public DateOnly Date { get; init; } = DateOnly.FromDateTime(DateTime.Now);
public int TemperatureC { get; init; } = 60;
public string? Summary { get; init; } = <span class="pl-pds">"Testing";
}
First a class to generate a data set. This is a Singleton pattern class (not a DI singleton). Methods such as GetRandomRecord
are for testing.
public sealed class WeatherTestDataProvider
{
private int RecordsToGenerate;
public IEnumerable<WeatherForecast> WeatherForecasts { get; private set; } =
Enumerable.Empty<WeatherForecast>();
private WeatherTestDataProvider()
=> this.Load();
public void LoadDbContext<TDbContext>(IDbContextFactory<TDbContext> factory)
where TDbContext : DbContext
{
using var dbContext = factory.CreateDbContext();
var weatherForcasts = dbContext.Set<WeatherForecast>();
if (weatherForcasts.Count() == 0)
{
dbContext.AddRange(this.WeatherForecasts);
dbContext.SaveChanges();
}
}
public void Load(int records = 100)
{
RecordsToGenerate = records;
if (WeatherForecasts.Count() == 0)
this.LoadForecasts();
}
private void LoadForecasts()
{
var forecasts = new List<WeatherForecast>();
for (var index = 0; index < RecordsToGenerate; index++)
{
var rec = new WeatherForecast
{
Uid = Guid.NewGuid(),
Summary = Summaries[Random.Shared.Next(Summaries.Length)],
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
};
forecasts.Add(rec);
}
this.WeatherForecasts = forecasts;
}
public WeatherForecast GetForecast()
{
return new WeatherForecast
{
Uid = Guid.NewGuid(),
Summary = Summaries[Random.Shared.Next(Summaries.Length)],
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(-1)),
TemperatureC = Random.Shared.Next(-20, 55),
};
}
public WeatherForecast? GetRandomRecord()
{
var record = new WeatherForecast();
if (this.WeatherForecasts.Count() > 0)
{
var ran = new Random().Next(0, WeatherForecasts.Count());
return this.WeatherForecasts.Skip(ran).FirstOrDefault();
}
return null;
}
private static WeatherTestDataProvider? _weatherTestData;
public static WeatherTestDataProvider Instance()
{
if (_weatherTestData is null)
_weatherTestData = new WeatherTestDataProvider();
return _weatherTestData;
}
public static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy",
"Hot", "Sweltering", "Scorching"
};
}
The DbContext
:
public sealed class InMemoryWeatherDbContext
: DbContext
{
public DbSet<WeatherForecast> WeatherForecast { get; set; } = default!;
public InMemoryWeatherDbContext
(DbContextOptions<InMemoryWeatherDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<WeatherForecast>().ToTable("WeatherForecast");
}
History
- 19th December, 2022: Initial version