Skip to content

Repository

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.

There are two repository surfaces in the persistence stack.

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();
}

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(...) and BulkUpsertAsync(...)
  • SaveChangesAsync(...)

Those methods are available on the generated concrete repository type, not on IRepository<TEntity, TId>.

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.cs
public 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);
}
NeedInject
Stable CRUD/specification accessIRepository<TEntity, TId>
Read-only stable accessIReadRepository<TEntity, TId>
Logic-key helper or include overloadOrder.Repository (nested concrete type)
Bulk methodsOrder.Repository (nested concrete type)
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 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);
}
}

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.

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;
}

The generator emits three different kinds of registration extensions:

  • Add{Boundary}DbContext(...)
  • Add{Boundary}Repositories()
  • Add{NamespacePrefix}QueryFilters() or AddGeneratedQueryFilters()

Example:

services.AddSalesDbContext(options => options.UseSqlite(connection));
services.AddSalesRepositories();
services.AddMyAppQueryFilters();

Important:

  • repositories are registered unkeyed for normal constructor injection
  • IUnitOfWork is registered keyed by boundary
  • logic-key helpers live on the generated repository class, not on the interface

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);