Skip to main content

How to Use ExecuteUpdateAsync and SaveChangesAsync in One Transaction

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

Changing a stock count with ExecuteUpdateAsync and recording the reason with SaveChangesAsync looks like one job, but those are two separate transactions unless you say otherwise. If the second fails, the count has changed and nothing explains why. Here's that failure, and the fix.

Prerequisites​

Step 1: See the Problem​

With no transaction, a receipt first adds to the quantity, and then saves a movement:

src/Dewiride.Inventory.Api/Features/Stock/StockEndpoints.cs (excerpt)
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),
cancellationToken);

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(cancellationToken);

Here, the movement's Reason was too long for its column, so the second write failed:

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-66d61688ad178fbd1893747253e082f2-f75d24ba4f5125ce-00"
}
Output (the quantity, and the sum of the movements)
+----------------+----------------+
| QuantityOnHand | SumOfMovements |
+----------------+----------------+
| 20 | 10 |
+----------------+----------------+

A table with QuantityOnHand 20 and SumOfMovements 10, after a request that failed.

The request failed, and the quantity went up anyway. They now disagree, by 10. The ExecuteUpdate page explains why: "they take effect immediately, at the point in which they are invoked."

Step 2: Put Both in One Transaction​

src/Dewiride.Inventory.Api/Features/Stock/StockEndpoints.cs (excerpt)
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);

The code in a macOS-style code window: BeginTransactionAsync, ExecuteUpdateAsync, SaveChangesAsync, then CommitAsync.

Both use the same connection, and so the same transaction, because they're on the same DbContext.

Step 3: Fail Again​

The same over-long Reason:

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-b87ab4325ceaf900f187926cdba3bfa2-e85453ff9dc019b1-00"
}
Output (MySQL's log)
+------------------------------------------------------------------------+
| statement |
+------------------------------------------------------------------------+
| SET TRANSACTION ISOLATION LEVEL REPEATABLE READ |
| BEGIN |
| UPDATE `products` AS `p` SET `QuantityOnHand` = `p`.`QuantityOnHand` + |
| SAVEPOINT `__EFSavePoint` |
| INSERT INTO `stock_movements` (`OccurredAtUtc`, `OrderId`, `ProductId` |
| ROLLBACK TO `__EFSavePoint` |
| RELEASE SAVEPOINT `__EFSavePoint` |
| ROLLBACK |
+------------------------------------------------------------------------+

MySQL got the UPDATE, the failed INSERT, and then ROLLBACK. The numbers still agree:

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

Step 4: Watch for Stale Entities​

Anything you loaded before an ExecuteUpdateAsync keeps its old values, because the method doesn't touch the change tracker. That's why the code in Step 2 reads the new quantity with a projection, and doesn't load the product.

Common Mistakes​

MistakeWhat happensDo this instead
Relying on SaveChangesAsync's transactionThe ExecuteUpdateAsync isn't in itStart your own
Calling them on different DbContextsTwo connections, two transactionsUse one, or share it
Saving a loaded entity after updating itIt can overwrite the new valueDon't mix them on one row

Troubleshooting​

SymptomLikely causeFix
does not support user-initiated transactionsRetries are onThe fix
The API returns the old valueA tracked entityThe fix
Data too long for columnThe request wasn't validatedValidate it first

Conclusion​

ExecuteUpdateAsync runs immediately, in its own transaction, unless one is already open. Begin a transaction, do both on the same DbContext, and commit.

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.