Lesson 19 of 45 advanced

Transactions and Concurrency

Keeping data correct when several things happen at once

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

Two users load the same row, and both change its Balance and save. With no concurrency token, what happens?

Open interactive version (quiz + challenge)

Real-world analogy

Two clerks open the same paper ledger and both read "balance: 100". One writes in "80", the other writes in "70", and the second pen stroke covers the first. Money has vanished and nobody noticed. A better ledger has a version stamp on each page: a clerk who writes on an old version is told "someone changed this, read it again". The stamp is optimistic concurrency, and a clerk who locks the page for others while writing is pessimistic locking.

What is it?

A transaction groups several database changes into one unit that either fully happens or does not happen at all. Concurrency is the other half: what happens when two requests touch the same row at the same moment. EF Core handles the simple case for you, because SaveChanges is already a transaction. For bigger flows you start one yourself. For conflicting edits you add a concurrency token, so the second writer is told instead of silently winning.

Real-world relevance

Money transfers, stock counts and seat bookings are where this bites. Two buyers click "buy" on the last ticket at the same moment, and without a concurrency token both succeed. Teams usually choose optimistic concurrency: it costs nothing when there is no conflict, and a conflict becomes an exception that the app turns into "this item changed, please review". Pessimistic locks are reserved for hot rows where retries would be constant.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

A method calls SaveChanges once with five modified entities. Do you need BeginTransaction to make them atomic?

What does EF Core throw when a [Timestamp] check fails during SaveChanges?

Code example

public class Account
{
    public int Id { get; set; }
    public decimal Balance { get; set; }
    [Timestamp] public byte[] RowVersion { get; set; } = null!;   // SQL Server rowversion
}

public async Task TransferAsync(ShopContext db, int fromId, int toId, decimal amount, CancellationToken ct)
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);

    var debited = await db.Accounts
        .Where(a => a.Id == fromId && a.Balance >= amount)
        .ExecuteUpdateAsync(s => s.SetProperty(a => a.Balance, a => a.Balance - amount), ct);
    if (debited == 0) throw new InvalidOperationException("Not enough money");

    await db.Accounts
        .Where(a => a.Id == toId)
        .ExecuteUpdateAsync(s => s.SetProperty(a => a.Balance, a => a.Balance + amount), ct);

    await tx.CommitAsync(ct);   // both changes become visible together
}

public async Task<bool> TryDepositAsync(ShopContext db, Account account, decimal amount, CancellationToken ct)
{
    account.Balance += amount;
    try { await db.SaveChangesAsync(ct); return true; }
    catch (DbUpdateConcurrencyException) { return false; }   // someone else changed the row first
}

Line-by-line walkthrough

  1. 1. Account has a RowVersion marked [Timestamp]. In SQL Server the database changes it on every update.
  2. 2. TransferAsync opens a transaction first, so everything after it stands or falls together.
  3. 3. The first ExecuteUpdateAsync takes money only if the balance is enough, and returns how many rows it changed.
  4. 4. If it changed 0 rows, we throw. Disposing the transaction rolls it back, so nothing is saved.
  5. 5. The second ExecuteUpdateAsync adds the money to the other account. CommitAsync then makes both changes permanent.
  6. 6. TryDepositAsync saves a tracked entity. If the RowVersion no longer matches, the exception tells us someone else won.

Spot the bug

public async Task PlaceOrderAsync(ShopContext db, Order order, int productId, int qty)
{
    db.Orders.Add(order);
    await db.SaveChangesAsync();

    // Stock has a [Timestamp] token, so a clash also throws below
    var stock = await db.Stock.FirstAsync(s => s.ProductId == productId);
    stock.Quantity -= qty;   // may violate a CHECK (Quantity >= 0) constraint
    await db.SaveChangesAsync();
}
Need a hint?
What state is the database in if the second SaveChanges throws?
Show answer
The first SaveChanges is already committed, so if the second one fails the order exists but the stock was never reduced. Fix: load the stock before saving and call SaveChanges once, or wrap both saves in BeginTransactionAsync and call CommitAsync at the end.

Explain like I'm 5

A transaction is a promise: either every step happens, or none of them do. When two people try to change the same thing, a version number lets the second one notice that someone got there first.

Fun fact

When a transaction is disposed without a commit, the database rolls it back. That is why an exception thrown halfway through TransferAsync leaves both balances untouched.

Hands-on challenge

Say it out loud in 30 seconds: "Two users edit the same record at the same time. How do you stop one from overwriting the other?" Then check: did you mention the lost update, a rowversion concurrency token with DbUpdateConcurrencyException, and pessimistic locking as the alternative?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook