Generic read/write repository abstractions for Entity Framework Core. Provides IReadRepository<T> and IWriteRepository<T> with full LINQ and async enumeration support, automatic dependency injection registration, and Moq helpers for unit testing.
The real power of Simpository is in custom repositories. By passing a pre-built queryable to the base constructor, you can bake in eager loading, filtering, and ordering so that every query — regardless of how it's called — always returns the correct, fully-populated object structure. See Extending the repositories ↓
dotnet add package Simpository
Call one of the extension methods in your Program.cs or startup code, passing your DbContext type:
// Read and write repositories for every DbSet<T> on AppDbContext
builder.Services.AddGenericRepos<AppDbContext>();
// Or register separately
builder.Services.AddGenericReadRepo<AppDbContext>();
builder.Services.AddGenericWriteRepo<AppDbContext>();This inspects all public DbSet<T> properties on the context and registers a scoped IReadRepository<T> / IWriteRepository<T> for each one automatically — no per-entity wiring required.
public class ProductService(IReadRepository<Product> products)
{
public Task<List<Product>> GetActiveAsync() =>
products.Where(p => p.IsActive).ToListAsync();
public Task<Product> GetByIdAsync(int id) =>
products.GetOrThrow(id);
}public class ProductService(IWriteRepository<Product> products)
{
public Task<Product> CreateAsync(Product product) =>
products.Add(product);
public Task DeleteAsync(int id) =>
products.Delete(id);
}IReadRepository<T> |
IWriteRepository<T> |
|
|---|---|---|
| Change tracking | Off (no-tracking) | On |
LINQ / ToListAsync |
✓ | ✓ |
async foreach |
✓ | ✓ |
| Add / Update / Delete | ✗ | ✓ |
Prefer IReadRepository<T> wherever mutations are not needed — no-tracking queries are faster and avoid accidental saves.
// Uses EF Core's FindAsync — checks the change tracker before hitting the database
Task<T?> Find(object keyValue, CancellationToken ct = default);
Task<T?> Find(object[] keyValues, CancellationToken ct = default); // composite keys
Task<T> FindOrThrow(object keyValue, CancellationToken ct = default);
Task<T> FindOrThrow(object[] keyValues, CancellationToken ct = default);
// Always queries the database via LINQ
Task<T?> Get(object key, CancellationToken ct = default);
Task<T> GetOrThrow(object key, CancellationToken ct = default);FindOrThrow / GetOrThrow throw DataNotFoundException<T> when the entity does not exist.
Finddelegates to EF Core'sFindAsync, which checks the change tracker first — useful in write scenarios where the entity may already be loaded.Getalways issues a LINQ query directly against the database — consistent behaviour in read-only scenarios.
IReadRepository<T> implements both IQueryable<T> and IAsyncEnumerable<T>, so you can use the full EF Core LINQ API:
// Filtering, projection, pagination
var page = await repo
.Where(p => p.IsActive)
.OrderBy(p => p.Name)
.Skip(20).Take(10)
.ToListAsync();
// Async streaming
await foreach (var item in repo)
Process(item);Extends IReadRepository<T> with mutation operations. All changes are saved immediately via SaveChangesAsync.
// Add
Task<T> Add(T entity, CancellationToken ct = default);
Task<IEnumerable<T>> Add(IEnumerable<T> entities, CancellationToken ct = default);
// Update
Task<T> Update(T entity, CancellationToken ct = default);
Task<IEnumerable<T>> Update(IEnumerable<T> entities, CancellationToken ct = default);
// Delete
Task Delete(T entity, CancellationToken ct = default);
Task Delete(object key, CancellationToken ct = default); // throws if not found
Task Delete(IEnumerable<T> entities, CancellationToken ct = default);Passing an empty collection to Add or Update returns immediately without touching the database.
Thrown by FindOrThrow, GetOrThrow, and Delete(object key) when the requested entity does not exist.
try
{
var product = await repo.GetOrThrow(42);
}
catch (DataNotFoundException<Product> ex)
{
// ex.Message => "Product not found with key 'Id' using: 42"
}| Method | Registers |
|---|---|
AddGenericReadRepo<TContext>() |
IReadRepository<T> → ReadRepository<T, TContext> (scoped) |
AddGenericWriteRepo<TContext>() |
IWriteRepository<T> → WriteRepository<T, TContext> (scoped) |
AddGenericRepos<TContext>() |
Both of the above |
Registration is driven by reflection — every public DbSet<T> property on TContext gets its own repository registration.
The Simpository.Mocking namespace provides Moq extension methods to configure repository mocks with in-memory data, supporting both synchronous LINQ and async enumeration.
var mockRepo = new Mock<IProductRepository>();
// With data
mockRepo.SetupData<IProductRepository, Product>(new List<Product>
{
new() { Id = 1, Name = "Widget" },
new() { Id = 2, Name = "Gadget" },
});
// Empty
mockRepo.SetupEmptyData<IProductRepository, Product>();Once configured, the mock supports the full LINQ API and ToListAsync:
var results = await mockRepo.Object
.Where(p => p.Name.StartsWith("W"))
.ToListAsync();Methods return the mock instance for chaining:
var mockRepo = new Mock<IProductRepository>()
.SetupData<IProductRepository, Product>(products);Inherit from ReadRepository<T, TContext> or WriteRepository<T, TContext> and pass a custom IQueryable<T> to the base constructor to bake in includes, filters, or ordering that apply to every query through that repository.
public class ProductRepository(AppDbContext context)
: ReadRepository<Product, AppDbContext>(
context,
context.Products.Include(x => x.Offers).OrderBy(x => x.Price))
{ }Every query through ProductRepository automatically gets the eager-loaded offers and price ordering — consumers never need to remember to add them, and every retrieval method returns the same fully-populated object structure:
var all = await repo.ToListAsync(); // List<Product>, each with Offers loaded
var cheap = await repo.Where(p => p.Price < 10)
.ToListAsync(); // still includes Offers, still ordered
var single = await repo.GetOrThrow(42); // single Product, Offers loaded
var searched = await repo.FirstOrDefaultAsync(...);// same shape, every timeNo matter which method a caller uses, they always get back the correct object structure — Offers is never null because someone forgot to .Include() it.
You can combine this with a typed interface to expose domain-specific methods:
public interface IProductRepository : IReadRepository<Product>
{
Task<List<Product>> GetFeaturedAsync();
}
public class ProductRepository(AppDbContext context)
: ReadRepository<Product, AppDbContext>(
context,
context.Products.Include(x => x.Offers).OrderBy(x => x.Price)),
IProductRepository
{
public Task<List<Product>> GetFeaturedAsync() =>
this.Where(p => p.IsFeatured).ToListAsync();
}Register the custom repository alongside or instead of the generic one:
builder.Services.AddScoped<IProductRepository, ProductRepository>();