Skip to content

Repository Implementation

Each entity needs a repository that:

  • wraps the correct DbSet<T>
  • resolves the right DbContext when multiple boundaries exist
  • applies filters on every read path
  • performs soft-delete correctly

That is all infrastructure code. The generator should own it.

For each entity, the generator emits a concrete repository class. That class implements IRepository<TEntity> and can also include convenience members such as:

  • logic-key helpers
  • include overloads
  • bulk methods

See the core Repository guide for the distinction between stable interfaces and the concrete generated surface.

Generated repositories resolve the boundary DbContext through keyed DI:

// Generated in {Namespace}.Order.Repository.g.cs
public partial class Order
{
public partial class Repository : IRepository<Order>
{
public Repository(
[FromKeyedServices(typeof(SalesBoundary))] DbContext db,
[FromKeyedServices(typeof(SalesBoundary))] IUnitOfWork unitOfWork,
IQueryFilterProvider? filterProvider = null,
FilterMapComposer? filterMapComposer = null,
ITenantContext? tenantContext = null,
IQueryFilterToggle? filterToggle = null,
IEnumerable<RollUpRule>? rollUpRules = null,
TimeProvider? timeProvider = null, // only on [Auditable] / [SoftDelete] entities
ICurrentUser? currentUser = null) // same
{ /* … */ }
}
}

Two things follow from that constructor. Both the context and the unit of work are keyed on the boundary, and on the same key, so the repository saves through the very instance an invoker holds, rather than a second one over the same context. And every collaborator after them is optional: a container missing the filter services builds the repository happily and reads unfiltered.

The generated repository registration method shape is:

// _Infra.Persistence.RepositoryRegistration.g.cs: one generic method for the whole assembly
public static IServiceCollection AddPragmaticPersistenceRepositories<TDbContext>(
this IServiceCollection services) where TDbContext : DbContext
{
// per boundary
services.AddKeyedScoped<DbContext>(typeof(SalesBoundary), (sp, _) => sp.GetRequiredService<TDbContext>());
services.AddKeyedScoped<IUnitOfWork>(typeof(SalesBoundary),
(sp, _) => new EfCoreUnitOfWork(sp.GetRequiredService<TDbContext>(), /* logger */ null, /* dispatcher */ null));
// per entity
services.AddScoped<Order.Repository>();
services.AddScoped<IRepository<Order>>(sp => sp.GetRequiredService<Order.Repository>());
services.AddScoped<IReadRepository<Order>>(sp => sp.GetRequiredService<Order.Repository>());
return services;
}

Inside a Pragmatic host you do not call it: the generated host emits the same registrations directly, in RegisterAllRepositories().

Important correction:

  • repositories are registered unkeyed for normal injection
  • keyed resolution is used for the DbContext and IUnitOfWork, not for the repository itself

Generated repositories apply both root and navigation filtering:

private IQueryable<Order> ApplyFilters(IQueryable<Order> query)
{
var context = BuildFilterContext();
if (_filterProvider is not null)
{
var filter = _filterProvider.GetCombinedFilter<Order>(
context,
NavigationContext.Root<Order>());
if (filter is not null)
query = query.Where(filter);
}
if (_filterMapComposer is not null)
query = _filterMapComposer.ApplyNavigationFilters(query, context);
return query;
}

If query-filter services are not registered, the repository still functions, but those filters are not applied.

For entities with [SoftDelete], Remove() becomes a metadata update, not a physical delete:

public void Remove(Order entity)
{
entity.IsDeleted = true;
entity.DeletedAt = _timeProvider.GetUtcNow();
entity.DeletedBy = _currentUser?.Id;
}

Entities without [SoftDelete] still use a physical EF Core remove.

EfCoreUnitOfWork wraps the boundary DbContext and is registered keyed by boundary:

var uow = services.GetRequiredKeyedService<IUnitOfWork>(typeof(SalesBoundary));
await uow.SaveChangesAsync(ct);

BeginTransactionAsync(...) returns ITransaction, not plain IAsyncDisposable.