Skip to main content

How to Use a Transaction with a Retrying Execution Strategy in EF Core

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

As soon as a DbContext has a retrying execution strategy, a plain BeginTransactionAsync stops working. The strategy has to know the whole group of statements, so it can run them all again. It only takes a wrapper, with one detail in the lambda and one at the top of it.

Prerequisites​

Step 1: See Why It's Needed​

A transaction outside the strategy fails on the first statement inside it:

API log
System.InvalidOperationException: The configured execution strategy 'MySQLRetryingExecutionStrategy' does not support user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.

Fixed: "The configured execution strategy does not support user-initiated transactions" has the whole story.

Step 2: Wrap the Transaction​

Ask the context for its strategy, and hand it a delegate with everything in it:

src/Dewiride.Inventory.Api/Features/Stock/StockEndpoints.cs (excerpt)
var strategy = db.Database.CreateExecutionStrategy();

return await strategy.ExecuteAsync(
async Task<Results<Created<StockChangeResponse>, NotFound>> (CancellationToken ct) =>
{
var now = clock.GetUtcNow().UtcDateTime;

// Nothing below is permanent until CommitAsync. Leaving this method without
// committing rolls everything back, when "await using" disposes of the transaction.
await using var transaction = await db.Database.BeginTransactionAsync(ct);

// First write: add to the quantity on hand.
var changed = await db.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(p => p.QuantityOnHand, p => p.QuantityOnHand + request.Quantity)
.SetProperty(p => p.UpdatedAtUtc, now),
ct);

if (changed == 0)
{
return TypedResults.NotFound();
}

// Second write: record why it changed.
var movement = new StockMovement
{
ProductId = productId,
Type = StockMovementType.Receipt,
QuantityChange = request.Quantity,
Reason = request.Reason,
Reference = request.Reference,
OccurredAtUtc = now,
};

db.StockMovements.Add(movement);
await db.SaveChangesAsync(ct);

var quantityOnHand = await db.Products
.Where(p => p.Id == productId)
.Select(p => p.QuantityOnHand)
.FirstAsync(ct);

await transaction.CommitAsync(ct);

return TypedResults.Created(
$"/api/v1/products/{productId}/stock/movements",
new StockChangeResponse(ToResponse(movement), quantityOnHand));
},
cancellationToken);

The method in a macOS-style code window: CreateExecutionStrategy, ExecuteAsync with an explicit lambda return type, BeginTransactionAsync, ExecuteUpdateAsync, SaveChangesAsync and CommitAsync.

Three things matter here:

  • Everything is inside. BeginTransactionAsync, all the work, and CommitAsync. If a retry happens, this whole delegate runs again.
  • The lambda states its return type. async Task<Results<...>> (CancellationToken ct) =>. The two returns have different types, and without this you get CS8031.
  • It uses ct, not the outer token. The strategy passes one in, along with cancellationToken at the end.

Step 3: Run It​

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/v1/products/9/stock/movements

{
"movement": {
"id": 2,
"productId": 9,
"type": "Receipt",
"quantityChange": 5,
"reason": "Second delivery",
"reference": "PO-1003",
"orderId": null,
"occurredAtUtc": "2026-11-05T05:12:38.543732Z"
},
"quantityOnHand": 15
}
API log
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20200]
Began transaction with isolation level 'Unspecified'.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20202]
Committed transaction.

And the two writes agree:

Output (the quantity, and the sum of the movements)
+----------------+----------------+
| QuantityOnHand | SumOfMovements |
+----------------+----------------+
| 15 | 15 |
+----------------+----------------+

Step 4: Make Every Attempt Start Clean​

The delegate may run more than once on the same DbContext. Two kinds of leftovers matter:

Entities from the failed attempt. Start with db.ChangeTracker.Clear(), or a retry can save a row twice:

An endpoint (excerpt)
return await strategy.ExecuteAsync(
async Task<Results<Created<StockChangeResponse>, NotFound>> (CancellationToken ct) =>
{
db.ChangeTracker.Clear();

await using var transaction = await db.Database.BeginTransactionAsync(ct);
// ...

Work that isn't in the database. A second email or a second charge won't roll back. Do those after ExecuteAsync returns.

If a repeat could apply the work twice because the first attempt really did commit, you also need a key.

Common Mistakes​

MistakeWhat happensDo this instead
Beginning outside the delegateThe same errorBegin inside
Sending an email in the delegateA retry sends it againSend it afterwards
Using a variable that keeps a total across attemptsIt counts the failed attempt tooCompute it inside, or reset it

Troubleshooting​

SymptomLikely causeFix
error CS8031The lambda has no return typeThe fix
No retries ever happenOracle's provider needs the numbers listedThe fix
Two rows after a retryThe tracker kept the first attempt's entityThe fix

Conclusion​

With retries on, put BeginTransactionAsync, your work and CommitAsync inside CreateExecutionStrategy().ExecuteAsync, state the lambda's return type, and clear the tracker first.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.