Phoenix.UOW
1.0.0
dotnet add package Phoenix.UOW --version 1.0.0
NuGet\Install-Package Phoenix.UOW -Version 1.0.0
<PackageReference Include="Phoenix.UOW" Version="1.0.0" />
<PackageVersion Include="Phoenix.UOW" Version="1.0.0" />
<PackageReference Include="Phoenix.UOW" />
paket add Phoenix.UOW --version 1.0.0
#r "nuget: Phoenix.UOW, 1.0.0"
#:package Phoenix.UOW@1.0.0
#addin nuget:?package=Phoenix.UOW&version=1.0.0
#tool nuget:?package=Phoenix.UOW&version=1.0.0
Phoenix.UOW
Phoenix.UOW is a small EF Core unit-of-work and generic repository package.
It provides:
IUnitOfWork,IUnitOfWork<TDbContext>, andUnitOfWork<TDbContext>IGenericRepository<T>andGenericRepository<T>- Base entity types (
BaseEntity,BaseAuditableEntity,BaseDeletableEntity) with time-ordered (GUID v7) primary keys - Soft-delete helpers and query-filter registration for
IDeletableEntity - Audit save-changes interception for create, update, soft delete, and restore metadata
- Ambient audit scopes:
AuditUserScope(per-request user under pooled contexts) andAuditSuppressionScope(sync appliers that preserve origin audit values) - Audited set-based bulk operations (
ExecuteUpdateAsync,ExecuteSoftDeleteAsync,ExecuteHardDeleteAsync) - Opt-in logical versioning (
IVersionedEntity/IVersionSource) for cross-database sync cursors - PostgreSQL optimistic concurrency via
BaseEntity.Xmin - Transaction-aware commit and execution helpers, with explicit
IsolationLeveloverloads RepositoryQuery<T>for filters, includes, custom query shaping, multi-sort, projection, paging, counts, aggregates, and min/max- Deterministic paging:
PageAsyncguarantees a total order (default sort or an automatic unique-key tie-breaker) - ASP.NET Core DI registration via
AddUnitOfWork<TDbContext>()
Install
dotnet add package Phoenix.UOW
Target frameworks
net8.0net10.0
The net8.0 asset depends on EF Core 8 packages. The net10.0 asset depends on EF Core 10 packages.
Quick start
1. Create entities
using Phoenix.UOW.Entities;
public sealed class Product : BaseAuditableEntity
{
public Product(string name, decimal price)
{
Name = name;
Price = price;
}
private Product()
{
}
public string Name { get; private set; } = string.Empty;
public decimal Price { get; private set; }
}
If your project already has base entities, implement IEntity or IAuditableEntity from Phoenix.UOW.Abstractions.
2. Register EF Core and Unit of Work
using Microsoft.EntityFrameworkCore;
using Phoenix.UOW;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddUnitOfWork<AppDbContext>();
AddUnitOfWork<TDbContext>() registers:
IUnitOfWorkIUnitOfWork<TDbContext>UnitOfWork<TDbContext>- open-generic
IGenericRepository<T>
Multiple DbContexts
Call AddUnitOfWork<TDbContext>() once per context and inject the closed generic IUnitOfWork<TDbContext> to pick a context explicitly — each closed generic targets its own context. The non-generic IUnitOfWork stays bound to the first registered context (registrations are TryAddScoped); a later AddUnitOfWork<OtherContext>() call does not rebind it:
builder.Services.AddUnitOfWork<AppDbContext>();
builder.Services.AddUnitOfWork<ReportingDbContext>(registerGenericRepositories: false);
public sealed class ReportService(IUnitOfWork<ReportingDbContext> unitOfWork)
{
// Repositories resolved via unitOfWork.GenericRepository<T>() use ReportingDbContext.
}
The open-generic IGenericRepository<T> registration resolves a single DbContext, so for additional contexts pass registerGenericRepositories: false and reach repositories through the matching IUnitOfWork<TDbContext> instead.
3. Configure soft delete
Use BaseDeletableEntity for entities that should be soft-deleted:
using Phoenix.UOW.Entities;
public sealed class Product : BaseDeletableEntity
{
public Product(string name)
{
Name = name;
}
private Product()
{
}
public string Name { get; private set; } = string.Empty;
}
Register the global soft-delete query filter in your DbContext:
using Microsoft.EntityFrameworkCore;
using Phoenix.UOW;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplySoftDeleteQueryFilters();
}
}
Delete soft-deletes entities that implement IDeletableEntity. Use HardDelete when you need a physical delete:
var repo = unitOfWork.GenericRepository<Product>();
repo.Delete(product);
await unitOfWork.CommitAsync(ct);
Product? deletedProduct = await repo.FirstOrDefaultAsync(
RepositoryQuery<Product>.Create().IgnoreSoftDeleteFilter(),
ct);
if (deletedProduct is not null)
{
repo.Restore(deletedProduct);
await unitOfWork.CommitAsync(ct);
}
HardDelete is honored even with the audit save-changes interceptor registered: the interceptor converts other Deleted entries of IDeletableEntity implementations (e.g. a raw context.Remove(...)) into soft deletes, but entities deleted through HardDelete/HardDeleteRange are physically removed — including purging a row that is already soft-deleted.
ApplySoftDeleteQueryFilters throws InvalidOperationException if an owned entity type implements IDeletableEntity. EF Core forbids query filters on owned types; manage soft-delete from the aggregate root instead.
IgnoreSoftDeleteFilter vs IgnoreQueryFilters
Use IgnoreSoftDeleteFilter() when you want to read soft-deleted rows but keep other query filters (multi-tenancy, scoping, etc.) in effect. Use IgnoreQueryFilters() to suppress every filter on the entity.
// Read soft-deleted rows but keep the tenant filter:
var query = RepositoryQuery<Product>.Create().IgnoreSoftDeleteFilter();
// Read everything, including the tenant filter:
var allRows = RepositoryQuery<Product>.Create().IgnoreQueryFilters();
On net10.0+, IgnoreSoftDeleteFilter() targets only the named Phoenix.UOW:SoftDelete filter and leaves other unnamed filters in place. On net8.0, EF Core does not support named filters, so IgnoreSoftDeleteFilter() is equivalent to IgnoreQueryFilters() (all filters are suppressed atomically). The constant ModelBuilderExtensions.SoftDeleteFilterName is exposed if you need to disable the filter inline on net10+:
.Apply(q => q.IgnoreQueryFilters([ModelBuilderExtensions.SoftDeleteFilterName]))
Soft delete and updates
Update throws InvalidOperationException when called on a soft-deleted entity. This prevents writing through to a row that is logically deleted (and that consumers expect to be immutable history). Call Restore first if you need to mutate a soft-deleted entity:
var repo = unitOfWork.GenericRepository<Product>();
var stale = await repo.FirstOrDefaultAsync(
RepositoryQuery<Product>.Create()
.IgnoreSoftDeleteFilter()
.Where(p => p.Id == id),
ct);
if (stale is { IsDeleted: true })
{
repo.Restore(stale);
// mutate as needed, then:
repo.Update(stale);
await unitOfWork.CommitAsync(ct);
}
Multiple instances with the same Id
Phoenix.UOW guards against the EF Core footgun where two different T instances with the same primary key are operated on through the same DbContext. SoftDelete, Restore, HardDelete, and any other write that calls EnsureAttached throw InvalidOperationException if a different reference with the same id is already in the change tracker. Re-fetch via the repository and operate on the tracked instance instead of carrying a stale reference across scopes.
Update is more lenient: when a different reference is already tracked, the caller's values are copied onto the tracked entry and that entry is marked Modified, with three protections:
- Audit columns survive. Framework-managed columns (
CreatedDate,CreatedById,IsDeleted,DeletedDate,DeletedById,RestoredDate,RestoredById) are restored from the tracked entry's original values after the copy, so a DTO-mapped fresh instance (which carriesCreatedDate = nowandCreatedById = Guid.Empty) cannot rewrite audit history. InsideAuditSuppressionScope.BeginPreserveIncoming()this restore is skipped so sync appliers can intentionally write these columns. - Soft-deleted rows stay guarded. If the tracked entry was originally soft-deleted,
Updatethrows the sameInvalidOperationExceptionas for a soft-deleted caller instance — a twin withIsDeleted == falsecannot silently "restore" the row. - Stale tokens are rejected. If the caller's instance carries a non-default row-version concurrency token (e.g.
Xmin != 0) that differs from the tracked entry's original token,UpdatethrowsDbUpdateConcurrencyException— matching the detached path, where the caller's token drives the concurrency check. A default token (Xmin == 0) means "no token supplied" and skips the check.
Server-generated columns (Xmin, identity columns, computed columns) are not propagated back onto the caller's reference — re-fetch if you need them.
4. Configure audit interception
Provide the current user id:
using Phoenix.UOW.Abstractions;
public sealed class CurrentUserAuditUserIdProvider : IAuditUserIdProvider
{
public Guid? GetAuditUserId()
{
// Return the authenticated user's id, or null for system work.
return null;
}
}
Register the interceptor and add it to EF Core. The interceptor is registered as a singleton and resolves IAuditUserIdProvider per save from the DbContext's own scope, so the pattern below is the canonical and recommended form:
using Phoenix.UOW;
using Phoenix.UOW.Interceptors;
builder.Services.AddAuditSaveChangesInterceptor<CurrentUserAuditUserIdProvider>();
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
options
.UseNpgsql(builder.Configuration.GetConnectionString("Default"))
.AddInterceptors(serviceProvider.GetRequiredService<AuditSaveChangesInterceptor>()));
The parameterless AddAuditSaveChangesInterceptor() overload registers a fallback provider that returns null, so the interceptor can still be resolved for system-only workloads. The constructor signature is AuditSaveChangesInterceptor(IAuditUserIdProvider? fallbackProvider = null, bool requireAuditUserId = false); the DI extension takes the same requireAuditUserId flag.
Require an audit user id
Pass requireAuditUserId: true to force every save of a new IAuditableEntity to come with a non-null user id. The interceptor throws InvalidOperationException instead of silently leaving CreatedById as Guid.Empty:
builder.Services.AddAuditSaveChangesInterceptor<CurrentUserAuditUserIdProvider>(requireAuditUserId: true);
This is recommended for any application that should never accept anonymous writes to auditable entities. Leave it off (the default) for mixed workloads where system-only code paths may save without a user context.
DbContext pooling
AddDbContextPool needs special care. Pooled DbContextOptions are a singleton, so the application service provider they capture is the root provider — a scoped IAuditUserIdProvider cannot be resolved per request from it:
- With scope validation enabled (the ASP.NET Core Development default), every save of an auditable entity throws. The interceptor detects this and rethrows an
InvalidOperationExceptionthat explains the limitation and points at the supported patterns below (the original root-provider error is preserved as the inner exception). - Without scope validation, a captive root-scope provider instance would be used for every request, stamping wrong or frozen user ids.
Two supported patterns for pooled contexts:
Ambient scope (recommended). Wrap each unit of work in
AuditUserScope.Begin(userId). The scope isAsyncLocal-based, so it flows acrossawaitand stays isolated between concurrent requests, and the interceptor checks it before touching the service provider:using (AuditUserScope.Begin(currentUserId)) { repo.Update(entity); await unitOfWork.CommitAsync(ct); }Singleton provider. Register
IAuditUserIdProvideras a singleton that reads ambient request state, e.g. backed byIHttpContextAccessor:builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton<IAuditUserIdProvider, HttpContextAuditUserIdProvider>(); builder.Services.AddAuditSaveChangesInterceptor(); // keeps the existing registration
With plain (non-pooled) AddDbContext, the options are scoped and per-save resolution of a scoped IAuditUserIdProvider works as documented above — no extra steps needed.
Sync appliers: preserving origin audit values
The interceptor normally owns the audit columns: it overwrites CreatedDate on insert and stamps UpdatedDate/UpdatedById on update. A sync-down/replication applier needs the opposite — persist the authoritative origin values so last-writer-wins conflict detection stays correct across the fleet. Wrap the apply in AuditSuppressionScope.BeginPreserveIncoming():
using (AuditSuppressionScope.BeginPreserveIncoming())
{
// Write audit fields (non-public setters) through EF's property API:
var entry = dbContext.Entry(entity);
entry.Property(nameof(BaseEntity.CreatedDate)).CurrentValue = origin.CreatedDate;
entry.Property(nameof(BaseAuditableEntity.CreatedById)).CurrentValue = origin.CreatedById;
entry.Property(nameof(BaseAuditableEntity.UpdatedDate)).CurrentValue = origin.UpdatedDate;
await dbContext.SaveChangesAsync(ct);
}
Inside the scope the interceptor performs no stamping at all (create, update, soft-delete, restore, and delete-conversion are all suppressed), and Update()'s protective restore of framework-managed columns (see below) is skipped. Both the repository mutation calls and the SaveChanges must run inside the scope.
No-op provider
NullAuditUserIdProvider is public and exposes a shared singleton:
var provider = NullAuditUserIdProvider.Instance;
Use it directly in unit tests or pass it to the interceptor constructor as a fallback when no DI container is available.
Audit metadata is interceptor-owned
BaseAuditableEntity.AuditCreate / AuditUpdate and BaseDeletableEntity.AuditDelete / AuditRestore are internal. Consumers cannot mutate audit fields manually — flow every audit change through the repository (SoftDelete, Restore, Update) and let the interceptor stamp the audit fields on save. Bypassing this path was a documented footgun in earlier versions; the surface is intentionally narrowed.
PostgreSQL concurrency
BaseEntity includes a uint Xmin property marked with EF Core's TimestampAttribute.
With the Npgsql EF Core provider, this maps to PostgreSQL's hidden xmin system column and gives database-managed optimistic concurrency.
xmin is database-local: it is only meaningful for optimistic concurrency against the same database, and it does not round-trip JSON. For cross-database sync cursors, see Logical versioning for cross-database sync.
This package is therefore PostgreSQL-first. If you need SQL Server or another provider, use your own base entity and implement IEntity or IAuditableEntity from Phoenix.UOW.Abstractions.
Time-ordered ids (GUID v7)
BaseEntity.Id defaults to an RFC 9562 version-7 GUID (48-bit Unix-millisecond timestamp + random bits) instead of the random version-4 Guid.NewGuid(). Random v4 keys fragment the PostgreSQL primary-key b-tree under insert load (every insert lands on a random page) and sort meaninglessly; v7 keys cluster sequential inserts onto adjacent pages and make ORDER BY id approximate creation order. On net10.0 this uses Guid.CreateVersion7(); on net8.0 an equivalent internal generator produces the same layout.
Ids minted within the same millisecond have no defined relative order, so treat Id ordering as an approximation of creation order, not a strict sequence. Existing v4 ids remain perfectly valid — v7 only changes the default for new entities.
5. Use repositories
using Phoenix.UOW.Abstractions;
public sealed class ProductService(IUnitOfWork unitOfWork)
{
public async Task<Guid> Create(string name, decimal price, CancellationToken ct)
{
var repo = unitOfWork.GenericRepository<Product>();
var product = new Product(name, price);
await repo.CreateAsync(product, ct);
await unitOfWork.CommitAsync(ct);
return product.Id;
}
}
Transactions
CommitAsync uses EF Core's execution strategy and reuses an existing transaction when one is already active. For a full operation wrapped in a transaction, use ExecuteInTransactionAsync:
string reference = await unitOfWork.ExecuteInTransactionAsync(
async cancellationToken =>
{
var repo = unitOfWork.GenericRepository<Order>();
var order = new Order("ORD-001");
await repo.CreateAsync(order, cancellationToken);
return order.Reference;
},
ct);
When an ambient transaction is already open on the DbContext (e.g. a nested ExecuteInTransactionAsync call), the operation joins it: no new transaction is started and the outermost owner controls commit.
Isolation levels
Both CommitAsync and ExecuteInTransactionAsync have overloads that take a System.Data.IsolationLevel, for business operations that need stronger guarantees than the provider default (e.g. RepeatableRead / Serializable for FIFO/LIFO inventory costing):
await unitOfWork.ExecuteInTransactionAsync(
IsolationLevel.Serializable,
async cancellationToken =>
{
// read-compute-write that must not interleave
},
ct);
await unitOfWork.CommitAsync(IsolationLevel.RepeatableRead, ct);
Unlike the overloads without an isolation level, these cannot join an ambient transaction: the isolation level of a running transaction cannot be changed, so silently joining it could violate the requested guarantee. They throw InvalidOperationException when a transaction is already open on the DbContext.
Side effects and retries
Both CommitAsync and ExecuteInTransactionAsync run the work under EF Core's configured execution strategy. If the strategy retries on transient failure (for example, Npgsql's EnableRetryOnFailure()), the operation lambda runs again on every retry. Anything inside the lambda that is not part of the SQL transaction — sending an email, publishing a message, calling a third-party API, mutating an in-memory cache — fires once per attempt.
The database side of a retry is kept clean for you: before each retry attempt, entities that became tracked during the previous failed attempt are detached from the change tracker, so an operation that constructs fresh entity instances on every invocation does not insert the failed attempt's instances alongside the new ones. Changes staged on the change tracker before the call survive retries and commit with the successful attempt. In-place mutations of entities that were already tracked before the call (property edits, state changes, detaches) are not reverted between attempts — keep the operation idempotent with respect to those.
// BAD: the email may be sent multiple times under retries.
await unitOfWork.ExecuteInTransactionAsync(async ct =>
{
await repo.CreateAsync(invoice, ct);
await emailService.SendInvoiceAsync(invoice, ct); // fires per attempt
}, ct);
Two safe patterns:
- Transactional outbox. Persist an outbox row inside the transaction, and let a background dispatcher deliver it exactly once. This is the recommended pattern for non-DB side effects.
- Side effects after the commit returns. Do the non-DB work after
ExecuteInTransactionAsync/CommitAsyncresolves — the transaction has finished retrying by then and you know exactly how many times the DB work succeeded.
// GOOD: send the email only after the DB commit succeeds.
await unitOfWork.ExecuteInTransactionAsync(async ct =>
{
await repo.CreateAsync(invoice, ct);
}, ct);
await emailService.SendInvoiceAsync(invoice, ct);
Query builder
using Microsoft.EntityFrameworkCore;
using Phoenix.UOW.Abstractions;
var repo = unitOfWork.GenericRepository<Product>();
var query = RepositoryQuery<Product>.Create()
.Where(product => product.Price > 0)
.Where(product => product.Name.Contains(searchTerm))
.Include(source => source
.Include(product => product.Categories.Where(category => category.IsActive)))
.OrderBy(product => product.Name)
.ThenByDescending(product => product.CreatedDate);
PagedResult<ProductListItem> products = await repo.PageAsync(
pageNum: 1,
pageSize: 20,
query,
selector: product => new ProductListItem(product.Id, product.Name, product.Price),
cancellationToken: ct);
Paging and ordering guarantees
Skip/Take over a query whose order is not total is an EF-documented wrong-results hazard: rows can be skipped or repeated across pages (PostgreSQL makes no ordering promise for ties, and split queries may order ties differently per statement). PageAsync therefore guarantees a total order:
- No ordering configured → a default sort of
OrderByDescending(CreatedDate).ThenByDescending(Id)is applied. OrderBy/ThenBysorts configured → aThenByDescending(Id)tie-breaker is appended automatically, unless one of the sorts already keys onId(from that point the order is already total).- Ordering supplied via
.Apply(...)transforms is detected by inspecting the composed expression tree and preserved as-is — the default sort is not stacked on top, and no tie-breaker is appended. End transform orderings in a unique key yourself. - The custom-projection overload
PageAsync(pageNum, pageSize, Func<IQueryable<T>, DbContext, IQueryable<TResult>>)cannot inject a default sort into an arbitrary projection, so it throwsInvalidOperationExceptionwhen the supplied query contains noOrderBy— order the query inside the transform, ending in a unique key.
Where calls are combined with AND. Use OrWhere when you need an OR predicate:
var query = RepositoryQuery<Product>.Create()
.Where(product => product.IsPublished)
.OrWhere(product => product.CreatedById == currentUserId);
Use the Include(Func<IQueryable<T>, IQueryable<T>>) overload for filtered includes and ThenInclude chains:
var query = RepositoryQuery<Order>.Create()
.Include(source => source
.Include(order => order.Items.Where(item => item.Quantity > 0))
.ThenInclude(item => item.Product));
For query-shaping features supplied by your EF provider, use Apply:
var query = RepositoryQuery<Order>.Create()
.Apply(source => source.AsSplitQuery())
.IgnoreQueryFilters();
Repository API
Repositories support basic state operations:
repo.Attach(product);
repo.Update(product);
repo.Delete(product); // Soft delete for IDeletableEntity, otherwise physical delete.
repo.HardDelete(product); // Always physical delete.
repo.SoftDelete(product); // Requires IDeletableEntity.
repo.Restore(product); // Requires IDeletableEntity.
HardDelete/HardDeleteRange mark the specific instance for physical removal, so the audit interceptor's delete-to-soft-delete conversion skips it (the marker survives failed/retried saves and is released after a successful save). "Always physical delete" holds even with the interceptor registered.
Tracking semantics
Queryable(withTracking: false) is the default. EF Core normally returns tracking queries from DbContext.Set<T>(); this library inverts that default because the majority of repository reads are projections or read-only renders, and untracked queries are both faster and keep the change tracker clean. Opt into tracking explicitly when you intend to mutate the loaded entity and persist via CommitAsync / SaveChangesAsync:
// Read-only render (default; no tracking):
var product = await repo.GetByIdAsync(productId, ct);
// Mutable load (tracking on):
var tracking = await repo.Queryable(withTracking: true)
.FirstOrDefaultAsync(p => p.Id == productId, ct);
tracking!.Rename("New name");
await unitOfWork.CommitAsync(ct);
RepositoryQuery<T> exposes the same toggle:
var query = RepositoryQuery<Product>.Create()
.WithTracking()
.Where(p => p.Id == productId);
var tracked = await repo.FirstOrDefaultAsync(query, ct);
Common query helpers include:
Product product = await repo.GetRequiredByIdAsync(productId, ct);
Product? single = await repo.SingleOrDefaultAsync(query, ct);
long count = await repo.LongCountAsync(query, ct);
decimal total = await repo.SumAsync(query, product => product.Price, ct);
decimal average = await repo.AverageAsync(query, product => product.Price, ct);
decimal min = await repo.MinAsync(query, product => product.Price, ct);
decimal max = await repo.MaxAsync(query, product => product.Price, ct);
Bulk operations (set-based UPDATE / DELETE)
For mass writes that must not load entities into the change tracker, the repository exposes audited wrappers over EF Core's ExecuteUpdate / ExecuteDelete:
// One SQL UPDATE; UpdatedDate (and UpdatedById when resolvable) auto-appended:
int updated = await repo.ExecuteUpdateAsync(
q => q.Where(p => p.Price < 0),
s => s.SetProperty(p => p.Price, 0m),
ct);
// One SQL UPDATE setting IsDeleted/DeletedDate/DeletedById (+ clears restore columns):
int softDeleted = await repo.ExecuteSoftDeleteAsync(
q => q.Where(p => p.DiscontinuedAt < cutoff),
ct);
// One SQL DELETE; rows are physically removed, no soft-delete conversion:
int purged = await repo.ExecuteHardDeleteAsync(
q => q.IgnoreSoftDeleteFilter().Where(p => p.IsDeleted && p.DeletedDate < retentionCutoff),
ct);
⚠️ Bulk operations bypass
SaveChanges— and therefore every save-changes interceptor — by design. That is exactly why these repository methods exist: they re-create the audit contract that the interceptor provides for tracked saves.ExecuteUpdateAsyncauto-appendsUpdatedDate = DateTime.UtcNow(andUpdatedByIdwhen an audit user is resolvable from the ambientAuditUserScopeor the registeredIAuditUserIdProvider) unless the caller already sets those properties;ExecuteSoftDeleteAsyncstamps the full soft-delete audit set. InsideAuditSuppressionScope.BeginPreserveIncoming()the auto-append is skipped, mirroring the interceptor.⚠️ Never call
Queryable().ExecuteDelete()/ExecuteUpdate()directly. A rawExecuteDeletephysically removesIDeletableEntityrows — the interceptor's delete-to-soft-delete conversion cannot intervene because noSaveChangesruns — and a rawExecuteUpdatewrites no audit columns. The repository methods above are the sanctioned path for set-based writes.
Rules and behaviors:
- Global query filters DO apply (they run through the query pipeline, unlike interceptors). With a default query, bulk operations only reach live rows; add
IgnoreSoftDeleteFilter()to the configured query to reach soft-deleted rows (e.g. for retention purges or re-stamping). - Includes throw. EF Core cannot translate
Includeinto a set-based statement, so a configured query carrying includes fails fast withInvalidOperationException. - Sorts are ignored — set-based statements are unordered.
- Tracked entities are not refreshed. Bulk statements change rows behind the change tracker's back; re-fetch affected entities afterwards.
IVersionedEntity.Versionis not stamped (that is interceptor-based); set it explicitly in the setters when bulk-updated rows must advance the sync cursor.- Target-framework note: the
settersargument follows the EF Core version of each target —Expression<Func<SetPropertyCalls<T>, SetPropertyCalls<T>>>onnet8.0(EF 8),Action<UpdateSettersBuilder<T>>onnet10.0(EF 10, which removedSetPropertyCalls). Thes => s.SetProperty(...)chain syntax is identical in source; onnet10.0the delegate must be pure because it can be invoked more than once.
Logical versioning for cross-database sync
BaseEntity.Xmin is valid only for same-database optimistic concurrency: PostgreSQL assigns it locally, it carries no meaning on another database, and it deliberately does not round-trip JSON (private setter). That breaks naive graph classification for sync payloads — a deserialized child of an existing row arrives with Xmin == 0, looks "new", and gets INSERTed into a PK violation.
Opt into logical versioning for entities that participate in sync:
public sealed class Product : BaseDeletableEntity, IVersionedEntity
{
public long Version { get; private set; } // keep the setter non-public
// ...
}
Register a version source (entirely opt-in — nothing changes without it):
// PostgreSQL: durable, monotonic across processes (SELECT nextval(...)).
builder.Services.AddVersionSource<PostgresSequenceVersionSource>();
// Tests / non-PostgreSQL providers: process-local Interlocked counter.
builder.Services.AddVersionSource(new InMemoryVersionSource());
Declare the sequence in your model so migrations create it:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasVersionSequence(); // "phoenix_uow_version_seq"
}
With a source registered, AuditSaveChangesInterceptor fetches one value per SaveChanges and stamps it on every inserted or updated IVersionedEntity in that save. Versions are therefore monotonic per database — exactly what a sync cursor needs (WHERE "Version" > @lastSeen). The stamp is skipped inside AuditSuppressionScope.BeginPreserveIncoming() so sync appliers persist the origin's Version verbatim.
GenericRepository<T>.Update's detached-graph classification consults IVersionedEntity before the xmin heuristic: a child with Version != 0 (and Xmin == 0, as deserialized payloads arrive) is classified Modified instead of Added, eliminating the PK-violation INSERT.
Sync drains: use an overlap window. Sequence values are handed out at save time, but transactions can commit out of order — a row with a smaller
Versionmay become visible after a row with a larger one. Do not treat the cursor as an exact high-water mark; re-scan with a small overlap (Version > lastSeen - overlap) and de-duplicate on the consumer side.nextvalis also non-transactional, so gaps (from rolled-back saves) are normal.
Joins
Use QueryAsync when a query needs a real LINQ join or a custom projection shape:
List<OrderListItem> orders = await unitOfWork.GenericRepository<Order>().QueryAsync(
(orders, db) => orders.Join(
db.Set<Customer>(),
order => order.CustomerId,
customer => customer.Id,
(order, customer) => new OrderListItem(order.Id, order.Number, customer.Name)),
ct);
Paged custom queries are supported too:
PagedResult<OrderListItem> orders = await unitOfWork.GenericRepository<Order>().PageAsync(
pageNum: 1,
pageSize: 20,
(orders, db) => orders
.Join(
db.Set<Customer>(),
order => order.CustomerId,
customer => customer.Id,
(order, customer) => new OrderListItem(order.Id, order.Number, customer.Name))
.OrderBy(order => order.Number),
ct);
| 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 is compatible. 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. |
-
net10.0
- Microsoft.EntityFrameworkCore (>= 10.0.8)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.8)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
-
net8.0
- Microsoft.EntityFrameworkCore (>= 8.0.27)
- Microsoft.EntityFrameworkCore.Relational (>= 8.0.27)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 128 | 7/13/2026 |
| 0.0.2-preview.1 | 79 | 5/30/2026 |
| 0.0.1-preview.1 | 70 | 5/23/2026 |
Fix pass: hard-delete semantics restored via marker mechanism; Update hardening (audit-column protection, soft-deleted-twin guard, concurrency-token check); deterministic paging; ExecuteInTransactionAsync retry hygiene; IsolationLevel overloads; IUnitOfWork<TDbContext>; AuditUserScope and AuditSuppressionScope; audited bulk operations; opt-in logical versioning (IVersionedEntity/IVersionSource); GUID v7 ids. See CHANGELOG.md.