Repository
The Problem
Section titled “The Problem”Every entity needs the same core data-access operations:
- load by primary key
- query by specification
- add, update, remove
- save through a unit of work
On top of that, the generated repositories may also provide convenience APIs such as logic-key lookups, include overloads, and bulk operations.
The important detail is that the stable interface surface is intentionally smaller than the generated concrete repository surface.
Two API Layers
Section titled “Two API Layers”There are two repository surfaces in the persistence stack.
1. Stable abstractions
Section titled “1. Stable abstractions”These are the interfaces you should prefer for application code that only needs the common CRUD/specification contract:
public interface IRepository<TEntity, TId> : IReadRepository<TEntity, TId> where TEntity : class, IEntity<TId> where TId : notnull{ void Add(TEntity entity); void AddRange(IEnumerable<TEntity> entities); void Remove(TEntity entity); void RemoveRange(IEnumerable<TEntity> entities); void Update(TEntity entity);}
public interface IReadRepository<TEntity, TId> where TEntity : class, IEntity<TId> where TId : notnull{ Task<TEntity?> GetByIdAsync(TId id, CancellationToken ct = default); Task<List<TEntity>> FindAsync(Specification<TEntity> spec, CancellationToken ct = default); Task<int> CountAsync(Specification<TEntity> spec, CancellationToken ct = default); Task<bool> ExistsAsync(Specification<TEntity> spec, CancellationToken ct = default); Task<TEntity?> FirstOrDefaultAsync(Specification<TEntity> spec, CancellationToken ct = default); IQueryable<TEntity> Query();}2. Generated concrete repository
Section titled “2. Generated concrete repository”The analyzer generates a concrete repository class per entity. That class can expose additional convenience members such as:
GetBy{LogicKey}Async(...)GetByIdAsync(..., includes, ...)- bulk methods like
BulkInsertAsync(...)andBulkUpsertAsync(...) SaveChangesAsync(...)
Those methods are available on the generated concrete repository type, not on IRepository<TEntity, TId>.
What the generated repository looks like
Section titled “What the generated repository looks like”The repository is generated as a nested class inside the entity’s partial class. This keeps the repository co-located with its entity and avoids top-level class proliferation.
// Generated in Order.Repository.g.cspublic partial class Order{ public sealed class Repository : IRepository<Order, Guid>{ public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default); public Task<List<Order>> FindAsync(Specification<Order> spec, CancellationToken ct = default); public Task<int> CountAsync(Specification<Order> spec, CancellationToken ct = default); public Task<bool> ExistsAsync(Specification<Order> spec, CancellationToken ct = default); public Task<Order?> FirstOrDefaultAsync(Specification<Order> spec, CancellationToken ct = default); public IQueryable<Order> Query();
public void Add(Order entity); public void AddRange(IEnumerable<Order> entities); public void Remove(Order entity); public void RemoveRange(IEnumerable<Order> entities); public void Update(Order entity);
// Concrete-repository-only helpers public Task<Order?> GetByOrderNumberAsync(string key, CancellationToken ct = default); public Task<Order?> GetByIdAsync( Guid id, Func<IQueryable<Order>, IQueryable<Order>> includes, CancellationToken ct = default);}Which one should you inject?
Section titled “Which one should you inject?”| Need | Inject |
|---|---|
| Stable CRUD/specification access | IRepository<TEntity, TId> |
| Read-only stable access | IReadRepository<TEntity, TId> |
| Logic-key helper or include overload | Order.Repository (nested concrete type) |
| Bulk methods | Order.Repository (nested concrete type) |
Using the stable interface
Section titled “Using the stable interface”using Microsoft.Extensions.DependencyInjection;
public sealed class OrderService( IRepository<Order, Guid> orders, IServiceProvider services){ public async Task<Guid> PlaceOrder(string number, decimal total, CancellationToken ct) { var order = Order.Create(number, total); orders.Add(order);
var uow = services.GetRequiredKeyedService<IUnitOfWork>(typeof(SalesBoundary)); await uow.SaveChangesAsync(ct);
return order.PersistenceId; }
public Task<List<Order>> GetLargeOrders(CancellationToken ct) => orders.FindAsync(Spec<Order>.Where(o => o.Total > 1000), ct);}Using the concrete repository
Section titled “Using the concrete repository”using Microsoft.Extensions.DependencyInjection;using Microsoft.EntityFrameworkCore;
public sealed class OrderQueries( Order.Repository orders, IServiceProvider services){ public Task<Order?> GetByNumber(string orderNumber, CancellationToken ct) => orders.GetByOrderNumberAsync(orderNumber, ct);
public Task<Order?> GetWithItems(Guid id, CancellationToken ct) => orders.GetByIdAsync(id, q => q.Include(o => o.Items), ct);
public Task<int> SaveAsync(CancellationToken ct) { var uow = services.GetRequiredKeyedService<IUnitOfWork>(typeof(SalesBoundary)); return uow.SaveChangesAsync(ct); }}Automatic filtering
Section titled “Automatic filtering”Every generated read path applies root filters automatically through IQueryFilterProvider.
That means:
- soft-deleted rows are excluded
- tenant and permission filters can be enforced centrally
- temporal filters are applied consistently
Use IQueryFilterToggle when a test, migration, or admin path needs to change filter behavior.
Unit of Work
Section titled “Unit of Work”IUnitOfWork is the persistence-agnostic save/transaction contract:
public interface IUnitOfWork : IDisposable, IAsyncDisposable{ Task<int> SaveChangesAsync(CancellationToken ct = default); void Add(object entity); Task<ITransaction> BeginTransactionAsync(CancellationToken ct = default);}Transactions are represented by ITransaction, not plain IAsyncDisposable.
await using var tx = await uow.BeginTransactionAsync(ct);try{ orders.Add(order); await uow.SaveChangesAsync(ct); await tx.CommitAsync(ct);}catch{ await tx.RollbackAsync(ct); throw;}DI registration
Section titled “DI registration”The generator emits three different kinds of registration extensions:
Add{Boundary}DbContext(...)Add{Boundary}Repositories()Add{NamespacePrefix}QueryFilters()orAddGeneratedQueryFilters()
Example:
services.AddSalesDbContext(options => options.UseSqlite(connection));services.AddSalesRepositories();services.AddMyAppQueryFilters();Important:
- repositories are registered unkeyed for normal constructor injection
IUnitOfWorkis registered keyed by boundary- logic-key helpers live on the generated repository class, not on the interface
Specifications
Section titled “Specifications”A specification is a reusable predicate:
var largeOrders = Spec<Order>.Where(o => o.Total > 1000);var pendingLargeOrders = largeOrders.And(Spec<Order>.Where(o => o.Status == OrderStatus.Pending));
var count = await orders.CountAsync(largeOrders, ct);var list = await orders.FindAsync(pendingLargeOrders, ct);var first = await orders.FirstOrDefaultAsync(pendingLargeOrders, ct);