Skip to main content
ANVISoftware Solutions
Lesson 12 of 17Intermediate14 min

Transactions

By the end of this lesson

Group related changes so they succeed or fail together.

A transaction is a group of database statements that either all take effect or none do. Commit makes the whole group permanent. Rollback discards all of it. There is no half-finished state for other connections to read.

This matters wherever a change needs more than one statement. Cancelling an order sets a status, returns stock to several products and writes an audit row. Those writes must not be allowed to disagree with each other.

Before reaching for transaction code, though, find out how much you already have.

Four rows, one call, all or nothing
C#
Order order = new()
{
    CustomerId = customerId,
    OrderDate = DateTime.UtcNow,
    Status = OrderStatus.Open
};

order.Items.Add(new OrderItem { ProductId = 91, Quantity = 2, UnitPrice = 24.99m });
order.Items.Add(new OrderItem { ProductId = 44, Quantity = 1, UnitPrice = 7.50m });

context.Orders.Add(order);

// No transaction code here, and the four rows are still atomic.
await context.SaveChangesAsync();
  • Adding the parent is enough. EF Core walks the navigation property, finds the items, and inserts them as well.
  • The order Id does not exist until the insert runs. EF Core reads the generated key back and sets OrderId on each item, in that sequence, before inserting them.
  • If the second item violated a foreign key, the whole batch would roll back. You would not be left with an order and one item, which is exactly the state that makes data hard to trust later.
The log for that single call (abbreviated)
SQL
BEGIN TRANSACTION;

INSERT INTO [Orders] ([CustomerId], [OrderDate], [Status])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2);

INSERT INTO [OrderItems] ([OrderId], [ProductId], [Quantity], [UnitPrice])
VALUES (@p3, @p4, @p5, @p6), (@p7, @p8, @p9, @p10);

COMMIT;
  • The BEGIN and the COMMIT were not in your code. EF Core added them because the save needed more than one statement.
  • The two order items went in as a single INSERT with two rows. EF Core batches statements to cut round trips, which is part of why saving once beats saving in a loop.
  • When a save needs only one statement, EF Core may skip the explicit transaction. A single statement is already atomic, so there is nothing to wrap.
An explicit transaction, spanning two saves and some SQL
C#
await using IDbContextTransaction transaction =
    await context.Database.BeginTransactionAsync();

try
{
    order.Status = OrderStatus.Cancelled;
    await context.SaveChangesAsync();

    // Returns stock to each product, and calls SaveChanges itself.
    await RestoreStockAsync(context, order);

    await context.Database.ExecuteSqlAsync(
        $"INSERT INTO [OrderAudit] ([OrderId], [Action]) VALUES ({order.Id}, 'Cancelled')");

    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}
  • BeginTransactionAsync opens a transaction on the connection. Every statement afterwards joins it, whether it came from SaveChanges or from raw SQL.
  • Nothing is permanent until CommitAsync. If RestoreStockAsync throws, the status change is discarded with it.
  • The catch rolls back and re-throws. Swallowing the exception here would leave the caller believing the cancellation succeeded.
  • await using means that if an exception escapes before the commit, disposing the transaction rolls it back anyway. The explicit rollback is there to make the intent obvious to the next reader.

Summary

  • A transaction makes a group of statements all-or-nothing: commit keeps them, rollback discards them
  • One SaveChanges call is already wrapped in a transaction, including all the rows it writes
  • Open a transaction yourself to span several SaveChanges calls, or to mix in raw SQL
  • Always commit or roll back, and re-throw rather than reporting a failed operation as success
  • Transactions hold locks, so keep them short and keep external calls outside them

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Describe the broken state

An order cancellation currently does three things, each with its own SaveChanges and no transaction: set the order status, return stock to three products, write an audit row.

The second save fails. Describe what the database now contains, what the user sees, and what the support team will find a week later.

Show solution

The order is marked Cancelled. Stock was returned to some products and not others, depending on where the failure landed. There is no audit row. Each individual save was atomic, so none of them is half-written — but the operation as a whole is.

The user most likely sees an error and assumes nothing happened, which is the worst version: they retry, the status is already Cancelled, and stock is returned twice.

A week later the stock figures are wrong and there is nothing in the audit table to explain it. That is the real cost of missing atomicity — not the failure itself, but that the evidence of it is gone.

One transaction around all three saves turns this into a clean failure: nothing changed, the user retries, and the second attempt is the first one that counted.

Try it yourself

Find the transaction you did not write

Turn on SQL logging. Create an Order with two OrderItems and save once. Find the BEGIN TRANSACTION and COMMIT in the log.

Now split it: save the order, then add the items and save again. Count the transactions.

Show solution

The single save produces one transaction around both inserts. The split version produces two, and there is a moment between them where an order exists with no items.

That gap is the point of the exercise. Another connection reading at the wrong instant sees an empty order, and if the second save fails the empty order is permanent.

It also shows why SaveChanges is worth calling once per unit of work: fewer transactions, fewer round trips, and a smaller window for someone else to see a partial state.

Saved in this browser only.