EF Core Transactions and SaveChangesAsync
This code has a bug:
await using (var transaction = await _context.Database.BeginTransactionAsync())
{
//some data changes
//...
//...
await _context.SaveChangesAsync();
//some more data changes
//...
//...
await transaction.CommitAsync();
}
If you've ever seen something like this, you might've noticed some of your changes didn’t make it to the database.
The part after SaveChangesAsync()
but before CommitAsync()
just doesn't get saved.
If you'd like a TL;DR here's a simple solution:
await using (var transaction = await _context.Database.BeginTransactionAsync())
{
//some data changes
//...
//...
await _context.SaveChangesAsync();
//some more data changes
//...
//...
+ await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
...and a simple explanation of what's going on:
CommitAsync()
does not save changes to the database by itself. You still need to callSaveChangesAsync()
before committing.
If you'd like to know more, the rest of the post will be going a bit deeper into how a transaction actually works. I'm going to use the phrase "open a transaction" instead of "begin a transaction" just because it feels more natural to me given the object-oriented nature of C# and EF Core. Just be aware that the technically correct term is "begin" both in EF Core and in SQL.
Note: My experience is with SQL Server, there may be some differences for other databases.
Why open a transaction?
First of all, transactions are not a tool exclusive to EF Core. The database itself requires a transaction for any operation and EF Core just allows you to interact with the database's transaction API through code. In fact,
When calling
SaveChangesAsync()
outside a manually opened transaction, EF Core still wraps the changes in a transaction. Some databases also have this implicit transaction feature.
So when would you manually open a transaction? All use cases boil down to this feature of transactions:
Transactions allow you to undo changes to the database.
The database keeps track of all changes made within the transaction and knows how to undo them if necessary. For example, if a row was added, the database knows it needs to delete that row to undo the change.
This is very useful if you want
to revert a bunch of changes if even one of them fails.
You may have noticed this behavior with SaveChangesAsync()
outside a manual transaction - if any one of the changes fails,
none of them get committed to the database.
So the common use case is when you have multiple
SaveChangesAsync()
calls and need to revert changes if one of them fails.
When looking at the very first code example, you might have thought:
"Why would there be multiple SaveChangesAsync()
calls in the code example?
Why not just have a single call at the end of all changes?".
Just imagine the first call is actually inside a service method:
public async Task SomeServiceMethod()
{
//some operations
await _context.SaveChangesAsync();
}
await using (var transaction = await _context.Database.BeginTransactionAsync())
{
//some data changes
//...
//...
await _someService.SomeServiceMethod();
//...
}
In some cases, you're not allowed to touch the service method because other parts of the application are relying on its behavior. Opening a manual transaction makes sense in this case.
I've seen a misconception that when called inside a manually
opened transaction, SaveChangesAsync()
doesn't actually write to
the database until CommitAsync()
is called. It does. In fact,
the behavior is useful in some cases - saving to the database
means the auto-generated values get generated by the database
and populated in the entities by EF Core which means you can
immediately start using those values:
await using (var transaction = await _context.Database.BeginTransactionAsync())
{
var myEntity = new MyEntity();
_context.Set<MyEntity>().Add(myEntity);
await _context.SaveChangesAsync();
myEntity.Id; //can already start using the database-generated Id value
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
Savepoints
Transactions have a checkpoint system called savepoints that let you undo part of your changes. Basically, you can save the state of the transaction at any point, and because the database always knows how to undo operations in a transaction, you can revert to a savepoint when you want without undoing everything.
I won't go into much detail about savepoints in this post - you rarely need to manually create and revert to savepoints. However, I want to show an interesting use case of savepoints.
Let's consider the classic explicit transaction use case again,
await using (var transaction = await _context.Database.BeginTransactionAsync())
{
//change1
await _context.SaveChangesAsync();
//change2
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
We have multiple SaveChangesAsync()
calls in a transaction
and we want to undo all changes if one of them fails.
What if in case of failure we want to undo only some of the changes?
Let's say the first SaveChangesAsync()
succeeds but the second one fails.
Here's what happens by default when the second call is executed:
- Before the changes are saved, a savepoint is created to capture the
transaction state after
change1
has been saved but beforechange2
is applied. - The database returns an error.
- Transaction reverts back to the savepoint.
- The related exception is thrown.
- The process flow goes out of the
using
scope and the transaction'sDisposeAsync()
method is automatically called. - All changes of the transaction are reverted inside
DisposeAsync()
.
Just before the exception is thrown, the state of the transaction
is the same as the moment just before the second SaveChangesAsync()
is executed. So it's basically as if the second call was never executed.
We can catch the exception and decide if we want to commit the transaction
which will commit only change1
. Or we can just revert all changes.
await using (var transaction = await _context.Database.BeginTransactionAsync())
{
try
{
//change1
await _context.SaveChangesAsync();
//change2
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (Exception ex)
{
if (commitPartial) //can use custom business logic
{
await transaction.CommitAsync();
}
else
{
await transaction.RollbackAsync();
throw;
}
}
}
By the way, it is generally recommended to always catch
the exceptions and manually rollback the transaction
instead of leaving it to DisposeAsync()
.
Though if you have lots of code using transactions,
things could get messy real fast.
In that case, you can wrap all the EF Core stuff in a
unit of work class and put the try-catch block inside the
SaveChangesAsync()
implementation.
public class UnitOfWork : IUnitOfWork
{
//rest of the class implementation omitted
public async Task SaveChangesAsync()
{
try
{
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
throw;
}
}
}