TCIS.Persistence.EntityFrameworkCore
1.0.0-rc.19
dotnet add package TCIS.Persistence.EntityFrameworkCore --version 1.0.0-rc.19
NuGet\Install-Package TCIS.Persistence.EntityFrameworkCore -Version 1.0.0-rc.19
<PackageReference Include="TCIS.Persistence.EntityFrameworkCore" Version="1.0.0-rc.19" />
<PackageVersion Include="TCIS.Persistence.EntityFrameworkCore" Version="1.0.0-rc.19" />
<PackageReference Include="TCIS.Persistence.EntityFrameworkCore" />
paket add TCIS.Persistence.EntityFrameworkCore --version 1.0.0-rc.19
#r "nuget: TCIS.Persistence.EntityFrameworkCore, 1.0.0-rc.19"
#:package TCIS.Persistence.EntityFrameworkCore@1.0.0-rc.19
#addin nuget:?package=TCIS.Persistence.EntityFrameworkCore&version=1.0.0-rc.19&prerelease
#tool nuget:?package=TCIS.Persistence.EntityFrameworkCore&version=1.0.0-rc.19&prerelease
TCIS.Persistence.EntityFrameworkCore
The standard write path of the TCIS ecosystem: IUnitOfWork, IGenericRepository<T>, the Specification pattern, audit stamping, and database exception classification — built on Entity Framework Core.
Use this package for writes. Use
TCIS.Persistence.Dapperfor complex reads. This package registers both.
Table of contents
| Section | Contents |
|---|---|
| 1 | Installation and registration |
| 2 | IUnitOfWork + IGenericRepository<T> |
| 3 | Specification — reusable queries |
| 4 | Transactions |
| 5 | Audit stamping |
| 6 | Database exception classification |
| 7 | Pitfalls |
| 8 | Writing tests |
1. Installation and registration
dotnet add package TCIS.Persistence.EntityFrameworkCore
appsettings.json
{
"ConfigurationStore": {
"ConnectionString": "Server=db;Database=Tcis;User Id=app;Password=***;TrustServerCertificate=True",
"ReadConnectionString": "Server=db-replica;Database=Tcis;User Id=app_ro;Password=***;TrustServerCertificate=True"
}
}
ConnectionString is required — if it is missing the application fails to start (ValidateOnStart) instead of dying on the first request in production.
ReadConnectionString is optional; when omitted, the read path falls back to the primary string.
Program.cs
using Microsoft.Data.SqlClient; // NOT System.Data.SqlClient (deprecated)
using Microsoft.EntityFrameworkCore;
builder.Services.AddTCorePersistence<AppDbContext>(
configuration: builder.Configuration,
dbConnectionCreator: cs => new SqlConnection(cs), // for Dapper
dbOptionsAction: (sp, options) => // for EF Core
{
options.UseSqlServer(
builder.Configuration["ConfigurationStore:ConnectionString"],
sql => sql.CommandTimeout(30));
});
The last two parameters are separate because the two paths differ: dbConnectionCreator produces a raw IDbConnection for Dapper, while dbOptionsAction configures the provider for EF Core.
What gets registered
| Service | Implementation | Lifetime |
|---|---|---|
IOptions<PersistenceOptions> |
bound to the ConfigurationStore section |
Singleton |
IDbConnectionFactory |
DefaultDbConnectionFactory |
Scoped |
TAppDbContext |
yours | Scoped |
DbContext |
resolves to TAppDbContext (the same instance) |
Scoped |
IDapperContext |
DapperContext |
Scoped |
IUnitOfWork |
EfUnitOfWork |
Scoped |
IGenericRepository<> |
GenericRepository<> |
Scoped |
DbContextis mapped through the factorysp => sp.GetRequiredService<TAppDbContext>(), soIUnitOfWorkand the repositories share exactly one instance with code that injectsAppDbContextdirectly.
2. IUnitOfWork + IGenericRepository<T>
public sealed class CreateOrderHandler(IUnitOfWork uow)
{
public async Task<Guid> HandleAsync(CreateOrderCommand cmd, CancellationToken ct)
{
var repo = uow.Repository<Order>();
if (await repo.AnyAsync(o => o.OrderNo == cmd.OrderNo, ct))
{
throw new TValidationException("GUARD_DUPLICATE_ORDER", $"Order {cmd.OrderNo} already exists.", "orders");
}
var order = new Order { Id = Guid.NewGuid(), OrderNo = cmd.OrderNo, CustomerCode = cmd.CustomerCode };
repo.Add(order);
await uow.SaveChangesAsync(ct); // <- nothing reaches the database until this runs
return order.Id;
}
}
The full IGenericRepository<T> surface
// Reads
Task<T?> GetByIdAsync(object id, ct);
Task<List<T>> GetAllAsync(ct);
Task<List<T>> GetPagedAsync(int page, int pageSize, ct); // page is 1-based
Task<List<T>> FindAsync(Expression<Func<T,bool>> predicate, ct);
Task<T?> FirstOrDefaultAsync(Expression<Func<T,bool>> predicate, ct);
Task<bool> AnyAsync(Expression<Func<T,bool>> predicate, ct);
Task<int> CountAsync(Expression<Func<T,bool>>? predicate, ct);
// Reads driven by a Specification — see section 3
Task<T?> GetEntityWithSpecAsync(ISpecification<T> spec, ct);
Task<IReadOnlyList<T>> ListWithSpecAsync(ISpecification<T> spec, ct);
Task<int> CountWithSpecAsync(ISpecification<T> spec, ct);
Task<bool> AnyWithSpecAsync(ISpecification<T> spec, ct);
// Writes — these do NOT save; you must call SaveChangesAsync
void Add(T entity); void AddRange(IEnumerable<T> entities);
void Update(T entity);
void Delete(T entity); void DeleteRange(IEnumerable<T> entities);
GetPagedAsync numbers pages from 1. Passing page = 0 or pageSize < 1 throws TValidationException (GUARD_INVALID_PAGE → HTTP 422) at the boundary, instead of emitting OFFSET -n ROWS and getting a SQL syntax error back from the database.
A dedicated repository for richer behaviour
Derive from GenericRepository<T> and register it — uow.Repository<T>() prefers your implementation automatically:
public sealed class OrderRepository(DbContext context) : GenericRepository<Order>(context)
{
// Query() is the extension point: every read method goes through it
protected override IQueryable<Order> Query()
=> base.Query().Where(o => !o.IsArchived);
public Task<List<Order>> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct)
=> Query().Where(o => o.DueDate < asOf && o.Status != OrderStatus.Paid).ToListAsync(ct);
}
// Program.cs
builder.Services.AddScoped<IGenericRepository<Order>, OrderRepository>();
3. Specification — reusable queries
Use this when a filter is repeated across call sites, or when you need to bundle filtering, includes, ordering and paging into a single object you can pass around.
public sealed class OverdueOrdersSpec : BaseSpecification<Order>
{
public OverdueOrdersSpec(string customerCode, int page, int pageSize)
: base(o => o.CustomerCode == customerCode && o.Status == OrderStatus.Pending)
{
AddInclude(o => o.Lines);
AddInclude(o => o.Customer);
ApplyOrderByDescending(o => o.CreatedAt);
ApplyPaging((page - 1) * pageSize, pageSize);
ApplyAsNoTracking(); // read-only -> skip change tracking
}
}
// Usage
var spec = new OverdueOrdersSpec("CUST-01", page: 2, pageSize: 20);
var items = await uow.Repository<Order>().ListWithSpecAsync(spec, ct);
var total = await uow.Repository<Order>().CountWithSpecAsync(spec, ct);
The order of application is fixed: filter → includes → ordering → paging.
⚠️ Paging must always be accompanied by ordering.
ApplyPagingwithoutApplyOrderBy/ApplyOrderByDescendingproduces undefined results: row order is up to the database and may change between runs, so two consecutive pages can repeat rows or skip rows with no error at all. SeeDB-030(Chapter 6).
ApplyAsNoTracking()is for read-only queries: skipping change tracking is faster and uses less memory. Do not use it when you intend to modify the entity and callSaveChangesAsync— EF will not see the changes.
4. Transactions
4.1. Most of the time you do NOT need an explicit transaction
SaveChangesAsync is already a transaction. EF Core opens one itself whenever it has to send more than one command to the database.
// ✅ ENOUGH — all of it commits together or none of it does
foreach (var e in plan.ToInsert) repo.Add(e);
foreach (var e in plan.ToUpdate) repo.Update(e);
await uow.SaveChangesAsync(ct);
4.2. When you actually need one
Only when several SaveChanges calls must succeed or fail together:
| Situation | Why |
|---|---|
Save the parent to obtain its Id, run logic, then save children |
Two SaveChanges calls |
| Mixing EF with raw SQL on the same connection | Two write mechanisms |
| Write, read back the uncommitted data, then decide whether to commit | You need to see uncommitted data |
4.3. The canonical pattern
await uow.BeginTransactionAsync(ct);
try
{
var order = new Order { OrderNo = cmd.OrderNo };
uow.Repository<Order>().Add(order);
await uow.SaveChangesAsync(ct); // first save — order.Id is now assigned
foreach (var line in BuildLines(order.Id)) // logic that needs the Id above
uow.Repository<OrderLine>().Add(line);
await uow.CommitTransactionAsync(ct); // final SaveChanges + Commit
}
catch
{
await uow.RollbackTransactionAsync(ct);
throw;
}
Three things that are easy to get wrong:
CommitTransactionAsyncalready callsSaveChangesAsync. Do not callSaveChangesAsyncimmediately before it.- The
try/catchis still required, even thoughCommitTransactionAsyncrolls back on its own when saving or committing fails. Reason: an exception thrown before control reaches the commit line (sayBuildLinesthrows) means the commit never runs — without thecatch, the transaction is only disposed when the scope ends, and database locks are held for that whole stretch. - No manual rollback is needed after
CommitTransactionAsyncthrows — it has already rolled back and released the transaction.
4.4. Transactions do not nest
await uow.BeginTransactionAsync(ct);
await uow.BeginTransactionAsync(ct); // ❌ TValidationException: SYS_TRANSACTION_ALREADY_STARTED
EF Core does not support nested transactions. The second call used to return silently — the caller believed it had opened its own transaction, and its CommitTransactionAsync then committed the outer transaction, pushing work the outer scope had not finished down to the database.
Design consequence: transaction ownership belongs to the outermost orchestrator (the command handler), not to inner services. Inner services only Add / Update / SaveChangesAsync.
For a Pluggable pipeline, open the transaction around the whole pipeline at the call site; steps keep calling SaveChangesAsync as usual:
await uow.BeginTransactionAsync(ct);
try
{
await pipelineEngine.ExecuteAsync(context, ct); // steps still call SaveChanges normally
await uow.CommitTransactionAsync(ct);
}
catch
{
await uow.RollbackTransactionAsync(ct);
throw;
}
4.5. Dapper does NOT take part in this transaction
IDapperContext opens its own connection for every call. Inside a transaction it does not see uncommitted data, is not rolled back with it, and can block until CommandTimeout expires if it reads rows the EF transaction is holding locks on.
When you need raw SQL inside the transaction, borrow EF's connection:
var conn = dbContext.Database.GetDbConnection();
var tx = dbContext.Database.CurrentTransaction?.GetDbTransaction();
await conn.ExecuteAsync("UPDATE orders SET status = @s WHERE id = @id",
new { s = "PAID", id }, transaction: tx);
5. Audit stamping
⚠️
AuditSaveChangesInterceptoris NOT registered automatically byAddTCorePersistence. If you do not register it yourself, no audit field is ever populated, and nothing warns you. This step is mandatory.
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddTCorePersistence<AppDbContext>(
builder.Configuration,
cs => new SqlConnection(cs),
(sp, options) => options
.UseSqlServer(connectionString)
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
The interceptor takes identity from IUserContext, which TCIS.AspNetCore supplies through IWorkContext.
Two audit contracts — pick one per product
The platform serves existing products and new products side by side, which is why there are two sets of audit fields. This is deliberate, not duplication waiting to be cleaned up:
| Interface | Intended for | CreatedBy |
Timestamps | Populated from |
|---|---|---|---|---|
IAuditEntity |
Existing products — matches the column types of the legacy schema | int? |
DateTime (CreatedDate / UpdatedDate) |
IUserContext.UserId |
IAuditableEntity |
New products | string? |
DateTimeOffset (CreatedAt / UpdatedAt) |
IUserContext.Username (falls back to "System") |
public sealed class Order : IAuditableEntity
{
public Guid Id { get; set; }
public string OrderNo { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
How to choose: an entity belonging to a legacy product's schema uses IAuditEntity so the types line up with existing columns; an entity of a new product uses IAuditableEntity — DateTimeOffset carries the offset, and a string principal matches accounts issued by the IdP.
The two interfaces declare CreatedBy with different types (int? and string?), so an entity implementing both is forced into explicit interface implementation. In practice, don't: each entity picks exactly one set, according to the product it belongs to. The interceptor does support the both-interfaces case — it stamps both sets, leaving neither blank.
The interceptor only touches entities in the Added and Modified states; CreatedAt/CreatedBy are not overwritten on update.
6. Database exception classification
Turns raw database errors into a classification carrying an owner and a support tier — so alerts reach the right team instead of waking everybody.
builder.Services.AddExceptionClassification(); // TCIS.Observability.Classification
builder.Services.AddDatabaseExceptionClassification(); // this package — register it AFTER
| Failure | Classification | Retryable? |
|---|---|---|
| Deadlock (1205), timeout (-2) | Infrastructure / Ops / L3 |
✅ |
| Missing column, schema mismatch (207, 213) | Infrastructure / Ops / L3 |
❌ |
| Missing table or stored procedure | Infrastructure / Ops / L3 |
❌ |
| Data too long (8152, 2628) | Business / Business / L1 |
❌ |
It must be registered after AddExceptionClassification() so this precise rule wins over the coarse type-name rule.
7. Pitfalls
| # | Pitfall | Consequence |
|---|---|---|
| 1 | Forgetting SaveChangesAsync |
Add/Update/Delete only touch the change tracker — nothing reaches the database, and nothing errors |
| 2 | Calling an API / gRPC / broker inside a transaction | Network latency becomes lock-hold time (DB-036) |
| 3 | Enabling EnableRetryOnFailure while still using manual transactions |
EF throws — wrap the work in Database.CreateExecutionStrategy().ExecuteAsync(...) |
| 4 | Using IUnitOfWork in a background job without creating a scope |
It is Scoped — background jobs must using var scope = factory.CreateScope() |
| 5 | Expecting one transaction to span several DbContext instances |
A transaction spans exactly one DbContext — which is why cross-module work goes through integration events (ARC-021) |
| 6 | Calling ApplyAsNoTracking() and then modifying the entity |
EF sees no change; SaveChanges does nothing |
| 7 | Paging without ordering | Pages repeat or skip rows, silently |
8. Writing tests
GenericRepository, EfUnitOfWork and the interceptor are all testable against SQLite in-memory, no Docker required:
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open(); // keep it open for the lifetime of the test
var context = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
context.Database.EnsureCreated();
var uow = new EfUnitOfWork(context, new ServiceCollection().BuildServiceProvider());
var repo = uow.Repository<Order>();
repo.Add(new Order { OrderNo = "ORD-001" });
await uow.SaveChangesAsync();
Assert.Equal(1, await context.Set<Order>().CountAsync());
This package's own test suite (TCIS.Persistence.EntityFrameworkCore.Tests) is a fuller example: repository cache keying by Type, transaction semantics, disposal lifetime, paging guards, and audit stamping.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Dapper (>= 2.1.66)
- Microsoft.EntityFrameworkCore (>= 8.0.13)
- Microsoft.EntityFrameworkCore.Relational (>= 8.0.13)
- Microsoft.Extensions.Caching.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Logging (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 9.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)
- TCIS.Observability.Classification (>= 1.0.0-rc.19)
- TCIS.Persistence.Abstractions (>= 1.0.0-rc.19)
- TCIS.Persistence.Dapper (>= 1.0.0-rc.19)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on TCIS.Persistence.EntityFrameworkCore:
| Package | Downloads |
|---|---|
|
TCIS.Pluggable.Persistence.EntityFrameworkCore
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Pluggable Persistence EntityFrameworkCore |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.19 | 43 | 8/13/2026 |
| 1.0.0-rc.18 | 44 | 8/13/2026 |
| 1.0.0-rc.17 | 43 | 8/13/2026 |
| 1.0.0-rc.16 | 54 | 8/13/2026 |
| 1.0.0-rc.15 | 51 | 8/12/2026 |
| 1.0.0-rc.14 | 56 | 8/12/2026 |
| 1.0.0-rc.13 | 59 | 8/11/2026 |
| 1.0.0-rc.12 | 66 | 8/10/2026 |
| 1.0.0-rc.11 | 78 | 7/28/2026 |
| 1.0.0-rc.10 | 84 | 7/24/2026 |
| 1.0.0-rc.9 | 78 | 7/21/2026 |
| 1.0.0-rc.8 | 70 | 7/21/2026 |
| 1.0.0-rc.7 | 67 | 7/17/2026 |