Interview question
Retry an explicit EF Core transaction safely
Wraps a complete explicit transaction in the provider execution strategy and creates fresh context state for each retry attempt.
TL;DR
Wraps a complete explicit transaction in the provider execution strategy and creates fresh context state for each retry attempt.
Execution strategies, explicit transactions, fresh DbContext attempts, idempotency keys, commit uncertainty, and cancellation.
Practice the problem like a real interview: restate, reason, implement, and test.
A stock transfer updates two inventory rows and records StockTransfer(TransferId, ...) under one transaction. Transient database failures may be retried. Use IDbContextFactory<AppDbContext> so each retry gets a fresh context, and require a unique command TransferId.
Move to the linked follow-up, next path step, prerequisite, or deeper variant.
Practice the next layer of the same subject.
A transient failure before commit causes the whole delegate to run again with a new context. If the first commit actually succeeded but acknowledgement was lost, the unique transfer id prevents a second logical transfer.
I obtain the provider strategy, then put context creation, transaction start, database changes, save, and commit inside its delegate. A fresh context avoids carrying uncertain tracked state into a retry. The transfer id is still essential because a connection failure around commit can make the outcome unknown.
public static async Task TransferStockAsync(
IDbContextFactory<AppDbContext> contextFactory,
TransferStock command,
CancellationToken cancellationToken)
{
await using var strategyContext =
await contextFactory.CreateDbContextAsync(cancellationToken);
var strategy = strategyContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var db =
await contextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
if (await db.StockTransfers.AnyAsync(
transfer => transfer.Id == command.TransferId,
cancellationToken))
{
await transaction.CommitAsync(cancellationToken);
return;
}
await InventoryTransfer.ApplyAsync(db, command, cancellationToken);
db.StockTransfers.Add(StockTransfer.From(command));
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
});
}
Each attempt is a complete transactional unit. The unique transfer id must also exist as a database constraint because two concurrent first attempts can both pass the existence check. Retry counts, duration, and final failures should be observable so retries do not hide database saturation.
Spot a weak answer, missing edge case, or clearer explanation? Send it in.