Skip to main content
Version: 3.1.2

Entity Framework Core

Overview

The EF Core provider wires EFCoreRepository<TEntity> into the repository abstraction layer. It implements IReadOnlyRepository<TEntity>, IWriteOnlyRepository<TEntity>, ILinqRepository<TEntity>, and IGraphRepository<TEntity>, giving you full LINQ query support, eager loading, automatic soft-delete handling, and opt-in change-tracking control.

The provider resolves a RCommonDbContext-derived DbContext from the IDataStoreFactory at runtime, which makes it possible to register multiple named DbContext instances and route repositories to the right one.

Installation

NuGet Package
dotnet add package RCommon.EfCore

DbContext setup

Your DbContext must derive from RCommonDbContext rather than DbContext directly. RCommonDbContext implements IDataStore, which is the mechanism the IDataStoreFactory uses to resolve the correct context at runtime.

using Microsoft.EntityFrameworkCore;
using RCommon.Persistence.EFCore;

public class LeaveManagementDbContext : RCommonDbContext
{
public LeaveManagementDbContext(DbContextOptions<LeaveManagementDbContext> options)
: base(options)
{
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LeaveManagementDbContext).Assembly);
}

public DbSet<LeaveRequest> LeaveRequests { get; set; }
public DbSet<LeaveType> LeaveTypes { get; set; }
public DbSet<LeaveAllocation> LeaveAllocations { get; set; }
}

If you need audit stamping or other cross-cutting concerns, introduce an intermediate abstract class that still inherits from RCommonDbContext:

public abstract class AuditableDbContext : RCommonDbContext
{
private readonly ICurrentUser _currentUser;
private readonly ISystemTime _systemTime;

public AuditableDbContext(DbContextOptions options,
ICurrentUser currentUser, ISystemTime systemTime)
: base(options)
{
_currentUser = currentUser;
_systemTime = systemTime;
}

public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<BaseDomainEntity>()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified))
{
string userId = _currentUser?.Id?.ToString() ?? "System";
entry.Entity.DateLastModified = _systemTime.Now;
entry.Entity.LastModifiedBy = userId;

if (entry.State == EntityState.Added)
{
entry.Entity.DateCreated = _systemTime.Now;
entry.Entity.CreatedBy = userId;
}
}

return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
}

Configuration

Register the EF Core persistence provider in your application startup:

builder.Services.AddRCommon()
.WithPersistence<EFCorePersistenceBuilder>(ef =>
{
ef.AddDbContext<LeaveManagementDbContext>(
"LeaveManagement",
options => options.UseSqlServer(
builder.Configuration.GetConnectionString("LeaveManagement")));

ef.SetDefaultDataStore(ds =>
ds.DefaultDataStoreName = "LeaveManagement");
});

Multiple DbContexts can be registered by calling AddDbContext multiple times with different names:

ef.AddDbContext<InventoryDbContext>("Inventory",
options => options.UseNpgsql(inventoryConnectionString));

ef.AddDbContext<OrderingDbContext>("Ordering",
options => options.UseSqlServer(orderingConnectionString));

ef.SetDefaultDataStore(ds => ds.DefaultDataStoreName = "Ordering");

Modular composition

WithPersistence<EFCorePersistenceBuilder> is cache-aware. When two modules both call it, the second call reuses the cached EFCorePersistenceBuilder rather than constructing a new one — both configuration delegates run against the same instance, so each module can declare its own DbContexts side by side:

// In OrderingModule.Configure
services.AddRCommon()
.WithPersistence<EFCorePersistenceBuilder>(ef =>
ef.AddDbContext<OrderingDbContext>("Ordering",
o => o.UseSqlServer(orderingConn)));

// In InventoryModule.Configure
services.AddRCommon()
.WithPersistence<EFCorePersistenceBuilder>(ef =>
ef.AddDbContext<InventoryDbContext>("Inventory",
o => o.UseNpgsql(inventoryConn)));

After both modules run, IDataStoreFactory resolves "Ordering" and "Inventory" to their respective contexts. The EFCorePersistenceBuilder itself is constructed exactly once.

AddDbContext<TDbContext>(name, ...) has the following conflict semantics across modules:

Re-registration shapeBehaviour
Same name + same TDbContext (same options-builder allowed to differ)Idempotent — the duplicate is dropped.
Same name + different TDbContextThrows UnsupportedDataStoreException naming both context types.
Different name + same or different TDbContextBoth are registered.

See Modular Composition for the full conflict matrix.

Usage

Injecting and targeting a data store

Inject IGraphRepository<TEntity> (or any narrower interface) and set DataStoreName to match the name given to AddDbContext:

public class CreateLeaveTypeCommandHandler
{
private readonly IGraphRepository<LeaveType> _leaveTypeRepository;

public CreateLeaveTypeCommandHandler(IGraphRepository<LeaveType> leaveTypeRepository)
{
_leaveTypeRepository = leaveTypeRepository;
_leaveTypeRepository.DataStoreName = "LeaveManagement";
}

public async Task HandleAsync(CreateLeaveTypeCommand request, CancellationToken cancellationToken)
{
var leaveType = new LeaveType { Name = request.Name, DefaultDays = request.DefaultDays };
await _leaveTypeRepository.AddAsync(leaveType, cancellationToken);
}
}

CRUD operations

// Create
await _repo.AddAsync(entity, cancellationToken);
await _repo.AddRangeAsync(entities, cancellationToken);

// Read
LeaveType? lt = await _repo.FindAsync(id, cancellationToken);
var all = await _repo.FindAsync(lt => lt.DefaultDays > 0, cancellationToken);
var single = await _repo.FindSingleOrDefaultAsync(lt => lt.Name == "Annual", cancellationToken);
bool any = await _repo.AnyAsync(lt => lt.DefaultDays > 20, cancellationToken);
long count = await _repo.GetCountAsync(lt => lt.DefaultDays > 0, cancellationToken);

// Update
await _repo.UpdateAsync(entity, cancellationToken);

// Delete (auto-detects ISoftDelete)
await _repo.DeleteAsync(entity, cancellationToken);

// Bulk delete
int affected = await _repo.DeleteManyAsync(lt => lt.DefaultDays == 0, cancellationToken);

LINQ queries

Because IGraphRepository<TEntity> extends IQueryable<TEntity> you can use it directly in LINQ expressions:

IQueryable<LeaveType> query = _repo.FindQuery(lt => lt.DefaultDays > 5);

Eager loading

var requests = await _leaveRequestRepo
.Include(r => r.LeaveType)
.FindAsync(r => r.EmployeeId == employeeId, cancellationToken);

Chain additional navigation properties with ThenInclude:

var allocations = await _allocationRepo
.Include(a => a.LeaveType)
.FindAsync(a => a.EmployeeId == userId, cancellationToken);

Disabling change tracking

Set Tracking = false for read-only queries that do not need to participate in the change tracker:

_repo.Tracking = false;
var list = await _repo.FindAsync(lt => lt.DefaultDays > 0, cancellationToken);

Paged queries

IPaginatedList<LeaveType> page = await _repo.FindAsync(
expression: lt => lt.DefaultDays > 0,
orderByExpression: lt => lt.Name,
orderByAscending: true,
pageNumber: 1,
pageSize: 10,
token: cancellationToken);

Using specifications

var spec = new AllocationExistsSpec(userId, leaveTypeId, year);
long count = await _allocationRepo.GetCountAsync(spec, cancellationToken);

Read models

Alongside IGraphRepository<TEntity>, EFCorePersistenceBuilder's constructor also registers IReadModelRepository<TReadModel> (backed by EFCoreReadModelRepository<TReadModel>) for every type — no separate opt-in call is required. It targets IReadModel-marked projection/query types rather than full aggregates, and is specification-driven rather than exposing raw LINQ:

using RCommon.Persistence.Crud;

public class LeaveBalanceSummary : IReadModel
{
public Guid EmployeeId { get; set; }
public int RemainingDays { get; set; }
}

public class LeaveBalanceQueryHandler
{
private readonly IReadModelRepository<LeaveBalanceSummary> _readModels;

public LeaveBalanceQueryHandler(IReadModelRepository<LeaveBalanceSummary> readModels)
{
_readModels = readModels;
}

public async Task<LeaveBalanceSummary?> HandleAsync(Guid employeeId, CancellationToken cancellationToken)
{
return await _readModels.FindAsync(new EmployeeBalanceSpec(employeeId), cancellationToken);
}
}
MemberDescription
FindAsync(ISpecification<TReadModel>, CancellationToken)Returns the first match, or null.
FindAllAsync(ISpecification<TReadModel>, CancellationToken)Returns all matches as a read-only list.
GetPagedAsync(IPagedSpecification<TReadModel>, CancellationToken)Returns a paged result set.
GetCountAsync(ISpecification<TReadModel>, CancellationToken)Returns the count of matches.
AnyAsync(ISpecification<TReadModel>, CancellationToken)Returns whether any match exists.
Include<TProperty>(Expression<Func<TReadModel,TProperty>>)Eager-loads a navigation property; chainable.

IReadModelRepository<TReadModel> also implements INamedDataSource, so DataStoreName can be set the same way as on IGraphRepository<TEntity> to target a specific registered DbContext.

Aggregates are also registered automatically: IAggregateRepository<TAggregate,TKey> (via EFCoreAggregateRepository<,>) for loading and persisting aggregate roots with their child collections — see Aggregate Repository — and ISagaStore<TState,TKey> (via EFCoreSagaStore<,>) for saga/process-manager state — see Sagas.

API Summary

TypePurpose
IEFCorePersistenceBuilderFluent startup builder with AddDbContext<TDbContext> and SetDefaultDataStore
EFCorePersistenceBuilderConcrete implementation that registers EF Core repositories in the DI container. (The obsolete, misspelled EFCorePerisistenceBuilder name still compiles as a deprecated alias, but new code should use this corrected spelling.)
RCommonDbContextAbstract base class for all EF Core contexts; implements IDataStore
EFCoreRepository<TEntity>Concrete repository; implements IGraphRepository<TEntity> and lower interfaces
IGraphRepository<TEntity>Full CRUD + LINQ + paging + eager loading + Tracking property
IReadModelRepository<TReadModel>Specification-driven read access for IReadModel projection types; registered automatically alongside the write-side repositories
IAggregateRepository<TAggregate,TKey>Loads/persists aggregate roots and their child collections; see Aggregate Repository
ISagaStore<TState,TKey>Persists saga/process-manager state; see Sagas
RCommonRCommon