The classic repository pattern is a fairly 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 fundimentally good practices used in CQS and implement a fully generic provider.
This is not a regurgitated IRepository
implementation in DotNetCore with a few frills.
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.
The code is:
The Repo and latest version of this article are here: Blazr.IRepository.
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"); }
The following XUnit test demonstrates the basic datastore setup in DI. It:
[Fact] public async Task DBContextTest() { // Gets the control test data var testProvider = WeatherTestDataProvider.Instance(); // Build our services container var services = new ServiceCollection(); // Define the DbSet and Server Type for the DbContext Factory services.AddDbContextFactory<InMemoryWeatherDbContext>(options => options.UseInMemoryDatabase($"WeatherDatabase-{Guid.NewGuid().ToString()}")); var rootProvider = services.BuildServiceProvider(); //define a scoped container var providerScope = rootProvider.CreateScope(); var provider = providerScope.ServiceProvider; // get the DbContext factory and add the test data var factory = provider.GetService<IDbContextFactory<InMemoryWeatherDbContext>>(); if (factory is not null) WeatherTestDataProvider.Instance().LoadDbContext<InMemoryWeatherDbContext>(factory); // Check the data has been loaded var dbContext = factory!.CreateDbContext(); Assert.NotNull(dbContext); var count = dbContext.Set<WeatherForecast>().Count(); Assert.Equal(testProvider.WeatherForecasts.Count(), count); // Test an arbitary record var testRecord = testProvider.GetRandomRecord()!; var record = await dbContext.Set<WeatherForecast>().SingleOrDefaultAsync(item => item.Uid.Equals(testRecord.Uid)); Assert.Equal(testRecord, record); // Dispose of the resources correctly providerScope.Dispose(); rootProvider.Dispose(); }
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:
null
is returned, what does it mean?DBSet
contains a million records (maybe the DBA got something wrong last night)?Request objects encapulate what we request and result objects the data and status information we expect back. They are records
: defined once and then consumed.
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 Guids to give your records unique identifiers, then the database generated Id is status information.
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 present a few extra challenges.
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?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 are small single purpose classes that handle requests and return results. They abstract the nitty-gritty execution from the higher level Data Broker.
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.
SaveChangesAsync
, passing in the Cancellation token, and expects a single change to be reported.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.
The interface.
public interface IItemRequestHandler { public ValueTask<ItemQueryResult<TRecord>> ExecuteAsync<TRecord>(ItemQueryRequest request) where TRecord : class, new(); }
And the server implementation. Note:
IGuidIdentity
.FindAsync
which uses the inbuilt Key
methodology to get the record.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; // first check if the record implements IGuidIdentity. If so we can do a cast and then do the query via the Uid property directly if ((new TRecord()) is IGuidIdentity) record = await dbContext.Set<TRecord>().SingleOrDefaultAsync(item => ((IGuidIdentity)item).Uid == request.Uid, request.Cancellation); // Try and use the EF FindAsync implementation 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); } }
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(); }
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); }
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(); // Define the DbSet and Server Type for the DbContext Factory services.AddDbContextFactory<InMemoryWeatherDbContext>(options => options.UseInMemoryDatabase($"WeatherDatabase-{Guid.NewGuid().ToString()}")); // Define the Broker and Handlers 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>>(); // Create the container return services.BuildServiceProvider(); } private IDbContextFactory<InMemoryWeatherDbContext> GetPopulatedFactory(IServiceProvider provider) { // get the DbContext factory and add the test data 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() { // Get our test provider to use as our control var testProvider = WeatherTestDataProvider.Instance(); // Build the root DI Container var rootProvider = this.BuildRootContainer(); //define a scoped container var providerScope = rootProvider.CreateScope(); var provider = providerScope.ServiceProvider; // get the DbContext factory and add the test data var factory = this.GetPopulatedFactory(provider); // Check we can retrieve thw first 1000 records 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 Add Item test:
[Fact] public async Task AddItemTest() { // Get our test provider to use as our control var testProvider = WeatherTestDataProvider.Instance(); // Build the root DI Container var rootProvider = this.BuildRootContainer(); //define a scoped container var providerScope = rootProvider.CreateScope(); var provider = providerScope.ServiceProvider; // get the DbContext factory and add the test data var factory = this.GetPopulatedFactory(provider); // Check we can retrieve thw first 1000 records var dbContext = factory!.CreateDbContext(); Assert.NotNull(dbContext); var databroker = provider.GetRequiredService<IDataBroker>(); // Create a Test record var newRecord = new WeatherForecast { Uid = Guid.NewGuid(), Date = DateOnly.FromDateTime(DateTime.Now), TemperatureC = 50, Summary = "Add Testing" }; // Add the Record { var request = new CommandRequest<WeatherForecast>() { Item = newRecord }; var result = await databroker.CreateItemAsync<WeatherForecast>(request); Assert.NotNull(result); Assert.True(result.Successful); } // Get the new record { var request = new ItemQueryRequest() { Uid = newRecord.Uid }; var result = await databroker.GetItemAsync<WeatherForecast>(request); Assert.Equal(newRecord, result.Item); } // Check the record count has incremented { 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(); }
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.
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 arbitary 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; } = "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>(); // Check if we already have a full data set // If not clear down any existing data and start again 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"); }