Class DbContextIdempotencyExtensions
- Namespace
- Trellis.EntityFrameworkCore
- Assembly
- Trellis.EntityFrameworkCore.dll
Idempotency helpers for DbContext that convert provider-level unique-constraint violations into Result<TValue> failures with a structured Error.Conflict payload, so callers can express "insert this row unless it already exists" without having to write provider-specific exception handling at each call site.
public static class DbContextIdempotencyExtensions
- Inheritance
-
DbContextIdempotencyExtensions
- Inherited Members
Methods
TryInsertUniqueAsync<TEntity>(DbContext, TEntity, CancellationToken)
Adds entity to context, persists the change
via SaveChangesAsync(CancellationToken), and converts a
duplicate-key violation into Result.Fail(new Error.Conflict(Resource: null, Code: FaultCodes.DuplicateKey)).
Use this helper to implement idempotent inserts on a unique constraint without
catching DbUpdateException at the call site.
public static Task<Result<TEntity>> TryInsertUniqueAsync<TEntity>(this DbContext context, TEntity entity, CancellationToken cancellationToken = default) where TEntity : class
Parameters
contextDbContextThe DbContext to add the entity to.
entityTEntityThe entity instance to insert.
cancellationTokenCancellationTokenA token to observe while waiting for the save.
Returns
- Task<Result<TEntity>>
On success: Ok<TValue>(TValue) wrapping the same
entityinstance that was passed in (EF Core populates generated values such as primary keys, row versions, and timestamps in-place on that instance).On a unique-constraint violation: Fail<TValue>(Error) containing an Error.Conflict with Code = DuplicateKey and the provider-reported ConstraintName and ConstraintTableName populated on a best-effort basis (see ExtractConstraintIdentity(DbUpdateException)). On this path the change tracker is left exactly as the caller saw it on entry: every entry this call newly attached as Added (the root plus any owned / dependent / join entries reached through the navigation graph) is detached, and any already-tracked entry that Add(object) flipped to Added (e.g., an entity the context loaded earlier) is restored to its prior state.
Type Parameters
TEntityThe reference-type entity to insert.
Remarks
Exception propagation. Only DbUpdateException with a
duplicate-key inner exception is converted. All other exceptions —
DbUpdateConcurrencyException, foreign-key violations, other
DbUpdateException variants, connection/timeout exceptions, and
OperationCanceledException — propagate to the caller so that
global handlers and retry policies see them. When
OperationCanceledException is raised from
SaveChangesAsync(CancellationToken) (the
cancellationToken tripped mid-save) the helper still
performs the same change-tracker cleanup it performs on the duplicate-key
path before rethrowing, so a cancelled call leaves the change tracker
exactly as the caller saw it on entry — a later
SaveChangesAsync(CancellationToken) on the same
DI-scoped context will not silently insert the cancelled entity, and the
clean-context guard will not block a retry on the same context.
Clean-context requirement. The helper requires the DbContext
to have no pending changes before the call (ChangeTracker.HasChanges()
must be false). If the context already has tracked changes,
the helper throws InvalidOperationException because a duplicate-key
violation surfaced by SaveChangesAsync(CancellationToken)
cannot be unambiguously attributed to the entity being inserted — it could
belong to one of the pre-existing pending changes. Call
SaveChangesAsync(CancellationToken) first, use a fresh
context, or use the lower-level
SaveChangesResultAsync(DbContext, CancellationToken)
helper if you intentionally want to flush mixed changes.
Telemetry safety. The Error.Conflict.Detail string is a
safe generic message suitable for API responses. The constraint identity
fields are marked JsonIgnoreAttribute
on Error.Conflict and are intended for structured logging only.
Exceptions
- ArgumentNullException
Thrown when
contextorentityis null.- InvalidOperationException
Thrown when
contextalready has pending changes (HasChanges() is true) before the call.