Phoenix.UOW 1.0.0

dotnet add package Phoenix.UOW --version 1.0.0
                    
NuGet\Install-Package Phoenix.UOW -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Phoenix.UOW" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Phoenix.UOW" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Phoenix.UOW" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Phoenix.UOW --version 1.0.0
                    
#r "nuget: Phoenix.UOW, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Phoenix.UOW@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Phoenix.UOW&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Phoenix.UOW&version=1.0.0
                    
Install as a Cake Tool

Phoenix.UOW

Phoenix.UOW is a small EF Core unit-of-work and generic repository package.

It provides:

  • IUnitOfWork, IUnitOfWork<TDbContext>, and UnitOfWork<TDbContext>
  • IGenericRepository<T> and GenericRepository<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) and AuditSuppressionScope (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 IsolationLevel overloads
  • RepositoryQuery<T> for filters, includes, custom query shaping, multi-sort, projection, paging, counts, aggregates, and min/max
  • Deterministic paging: PageAsync guarantees 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.0
  • net10.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:

  • IUnitOfWork
  • IUnitOfWork<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 carries CreatedDate = now and CreatedById = Guid.Empty) cannot rewrite audit history. Inside AuditSuppressionScope.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, Update throws the same InvalidOperationException as for a soft-deleted caller instance — a twin with IsDeleted == false cannot 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, Update throws DbUpdateConcurrencyException — 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 InvalidOperationException that 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:

  1. Ambient scope (recommended). Wrap each unit of work in AuditUserScope.Begin(userId). The scope is AsyncLocal-based, so it flows across await and 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);
    }
    
  2. Singleton provider. Register IAuditUserIdProvider as a singleton that reads ambient request state, e.g. backed by IHttpContextAccessor:

    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:

  1. 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.
  2. Side effects after the commit returns. Do the non-DB work after ExecuteInTransactionAsync / CommitAsync resolves — 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/ThenBy sorts configured → a ThenByDescending(Id) tie-breaker is appended automatically, unless one of the sorts already keys on Id (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 throws InvalidOperationException when the supplied query contains no OrderBy — 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. ExecuteUpdateAsync auto-appends UpdatedDate = DateTime.UtcNow (and UpdatedById when an audit user is resolvable from the ambient AuditUserScope or the registered IAuditUserIdProvider) unless the caller already sets those properties; ExecuteSoftDeleteAsync stamps the full soft-delete audit set. Inside AuditSuppressionScope.BeginPreserveIncoming() the auto-append is skipped, mirroring the interceptor.

⚠️ Never call Queryable().ExecuteDelete() / ExecuteUpdate() directly. A raw ExecuteDelete physically removes IDeletableEntity rows — the interceptor's delete-to-soft-delete conversion cannot intervene because no SaveChanges runs — and a raw ExecuteUpdate writes 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 Include into a set-based statement, so a configured query carrying includes fails fast with InvalidOperationException.
  • 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.Version is 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 setters argument follows the EF Core version of each target — Expression<Func<SetPropertyCalls<T>, SetPropertyCalls<T>>> on net8.0 (EF 8), Action<UpdateSettersBuilder<T>> on net10.0 (EF 10, which removed SetPropertyCalls). The s => s.SetProperty(...) chain syntax is identical in source; on net10.0 the 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 Version may 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. nextval is 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.