Mert Özen Her Satırda Daha İleri
Veritabanı Dapper — #07

Dapper ile Transaction: BeginTransaction, Commit, Rollback ve TransactionScope

Mert Özen 6 Kas 2026 10 dk 1 görüntülenme
Dapper ile Transaction: BeginTransaction, Commit, Rollback ve TransactionScope

Yarım kalan bir siparişten yola çıkıp Dapper'da BeginTransaction, transaction: parametresi, commit ve dispose ile rollback'i kuruyoruz; parametreyi unutmanın SQLite'ta neden sessiz kaldığını, isolation level'ları ve TransactionScope'un async ve SQLite tuzaklarını gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar Dapper 2.1 ve .NET 10 içindir.

Önce Tahmin Et

Ada, stokta 4 adet kalan "The Left Hand of Darkness"tan 5 tane sipariş ediyor. Kod üç adımda çalışıyor: sipariş kaydını ekle, sipariş kalemini ekle, stoğu düş. books.stock column'unda check (stock >= 0) kısıtı var, yani üçüncü adım patlayacak. Soru şu: program bittiğinde orders ve order_items tablolarında kaç row var?

await using var conn = new SqliteConnection(cs);
await conn.OpenAsync();

try
{
    var orderId = await conn.ExecuteScalarAsync<long>(
        "insert into orders (customer, created_at) values (@customer, @createdAt) returning id",
        new { customer = "Ada", createdAt = "2026-11-04T10:00:00Z" });

    await conn.ExecuteAsync(
        "insert into order_items (order_id, book_id, quantity, unit_price) values (@orderId, @bookId, @quantity, @unitPrice)",
        new { orderId, bookId = 2, quantity = 5, unitPrice = 12.50 });

    await conn.ExecuteAsync(
        "update books set stock = stock - @quantity where id = @bookId",
        new { bookId = 2, quantity = 5 });
}
catch (SqliteException ex)
{
    Console.WriteLine(ex.Message);
}

var orders = await conn.ExecuteScalarAsync<int>("select count(*) from orders");
var items = await conn.ExecuteScalarAsync<int>("select count(*) from order_items");
Console.WriteLine($"orders: {orders}, order_items: {items}");

Cevabı vermeden önce bu yazının kurulumuna bakalım.

Hazırlık

Paketler bir önceki yazıdakiyle aynı: Dapper 2.1.89 ve Microsoft.Data.Sqlite 10.0.14. Bu sefer stoklu books, orders ve order_items tablolarına ihtiyacımız var. Her örnekte const string cs = "Data Source=bookstore.db"; ve await Setup.CreateAsync(cs); ile başlıyoruz:

using Dapper;
using Microsoft.Data.Sqlite;

public static class Setup
{
    public static async Task CreateAsync(string cs)
    {
        if (File.Exists("bookstore.db")) File.Delete("bookstore.db");
        await using var conn = new SqliteConnection(cs);
        await conn.ExecuteAsync("""
            create table books (
                id    integer primary key,
                title text not null,
                price real not null,
                stock integer not null check (stock >= 0)
            );
            create table orders (
                id         integer primary key,
                customer   text not null,
                created_at text not null
            );
            create table order_items (
                id         integer primary key,
                order_id   integer not null references orders(id),
                book_id    integer not null references books(id),
                quantity   integer not null,
                unit_price real not null
            );
            insert into books (id, title, price, stock) values
                (1, 'A Wizard of Earthsea', 9.99, 12),
                (2, 'The Left Hand of Darkness', 12.50, 4),
                (3, 'Dune', 14.99, 20);
            """);
    }
}

Cevap: Yarım Kalmış Bir Sipariş

SQLite Error 19: 'CHECK constraint failed: stock >= 0'.
orders: 1, order_items: 1

Stok düşülemedi ama sipariş ve kalemi veritabanında duruyor. Depoda olmayan bir kitap için müşteriye "siparişiniz alındı" e-postası gitmek üzere. Sebep basit: transaction açmadığında her statement kendi başına bir transaction'dır ve biter bitmez commit edilir. Veritabanı açısından üç bağımsız iş yaptın, ikisi başarılı oldu, biri olmadı.

Transaction'ın vaadi tam olarak bunu engellemek: içindeki statement'lar ya hep birlikte kalıcı olur ya da hiçbiri olmaz. Bankacılıktaki havale benzetmesi klişe ama doğru; para bir hesaptan çıkıp diğerine girmeden ortada kalamaz.

BeginTransaction ve transaction: Parametresi

Dapper'ın kendine ait bir transaction API'si yok. Transaction'ı ADO.NET'in kendisi açar, Dapper'a sadece her çağrıda hangi transaction içinde çalışacağını söylersin. Aynı sipariş akışını bir method'a alıp transaction'a koyalım:

static async Task<long> PlaceOrderAsync(DbConnection conn, string customer, int bookId, int quantity)
{
    await using var tx = await conn.BeginTransactionAsync();

    var orderId = await conn.ExecuteScalarAsync<long>(
        "insert into orders (customer, created_at) values (@customer, @createdAt) returning id",
        new { customer, createdAt = DateTime.UtcNow.ToString("O") },
        transaction: tx);

    await conn.ExecuteAsync(
        """
        insert into order_items (order_id, book_id, quantity, unit_price)
        select @orderId, id, @quantity, price from books where id = @bookId
        """,
        new { orderId, bookId, quantity },
        transaction: tx);

    await conn.ExecuteAsync(
        "update books set stock = stock - @quantity where id = @bookId",
        new { bookId, quantity },
        transaction: tx);

    await tx.CommitAsync();
    return orderId;
}

Bunu iki müşteriyle çağırıyoruz; Ada yine 5 tane istiyor, Alan 3 tane:

await using var conn = new SqliteConnection(cs);
await conn.OpenAsync();

await TryPlaceOrderAsync(conn, "Ada", bookId: 2, quantity: 5);
await TryPlaceOrderAsync(conn, "Alan", bookId: 2, quantity: 3);
await PrintAsync(conn);

static async Task TryPlaceOrderAsync(DbConnection conn, string customer, int bookId, int quantity)
{
    try
    {
        var orderId = await PlaceOrderAsync(conn, customer, bookId, quantity);
        Console.WriteLine($"{customer}: order {orderId} committed");
    }
    catch (SqliteException ex)
    {
        Console.WriteLine($"{customer}: rolled back ({ex.SqliteErrorCode})");
    }
}

static async Task PrintAsync(DbConnection conn)
{
    var orders = await conn.ExecuteScalarAsync<int>("select count(*) from orders");
    var items = await conn.ExecuteScalarAsync<int>("select count(*) from order_items");
    var stock = await conn.ExecuteScalarAsync<int>("select stock from books where id = 2");
    Console.WriteLine($"orders: {orders}, order_items: {items}, stock: {stock}");
}
Ada: rolled back (19)
Alan: order 1 committed
orders: 1, order_items: 1, stock: 1

Ada'nın siparişinden geriye hiçbir iz kalmadı, Alan'ınki eksiksiz yazıldı. Kodda hiçbir yerde RollbackAsync çağırmadığımıza dikkat et. update exception fırlatınca CommitAsync satırına hiç gelinmedi, await using bloğundan çıkılırken transaction dispose edildi ve commit edilmemiş bir transaction'ın dispose'u rollback demek. Açık bir catch içinde RollbackAsync yazmak da geçerli, ama "commit'e ulaşamayan her şey geri alınır" deseni hem daha kısa hem de yeni bir hata yolu eklendiğinde unutulmaz.

Küçük bir detay: Alan'ın siparişi 1 numarayı aldı. Ada'nın insert'i de 1 numarayı almıştı ama rollback ile birlikte o row yok oldu. SQLite'ta AUTOINCREMENT olmayan bir integer primary key en büyük id'nin bir fazlasını verdiği için numara yeniden kullanıldı. SQL Server'daki IDENTITY ise rollback'te geri sarılmaz ve numarada boşluk bırakır. Id'lerin ardışık olacağına hiçbir veritabanında güvenme.

Bir de connection'ı açıkça açtığımıza dikkat et. Önceki yazıda gördüğümüz gibi Dapper kapalı bir connection'ı her çağrı için açıp kapatır; transaction ise açık bir connection'a bağlı yaşar. BeginTransactionAsync zaten açık connection ister.

transaction: Parametresini Unutursan

Gerçek kodda en sık yapılan hata, üç çağrıdan birinde transaction: tx yazmayı unutmak. SQLite'ta ne olduğuna bakalım:

await using (var tx = await conn.BeginTransactionAsync())
{
    try
    {
        // transaction: tx unutuldu
        await conn.ExecuteAsync("update books set stock = 0 where id = 1");
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine(ex.Message);
    }
}

await using (var tx = await conn.BeginTransactionAsync())
{
    await conn.ExecuteAsync("update books set stock = 0 where id = 1", transaction: tx);
}
Console.WriteLine($"stock after dispose: {await conn.ExecuteScalarAsync<int>("select stock from books where id = 1")}");
stock after dispose: 12

İlk blokta exception yok, catch hiç çalışmadı. Microsoft.Data.Sqlite'ın CreateCommand method'u yeni command'ın Transaction property'sine connection'daki aktif transaction'ı kendisi atıyor; Dapper da yalnızca transaction argümanı null değilse bu property'ye dokunuyor. Sonuç: SQLite'ta unutulan transaction: sessizce doğru transaction'a katılıyor. İkinci blok da commit edilmeden dispose edilen transaction'ın gerçekten geri alındığını gösteriyor; stok 12'de kaldı.

Bu bir rahatlık değil, bir tuzak. SqlClient aynı durumda command'ı kendi hâline bırakır ve "connection'da bekleyen bir local transaction var ama command'da yok" diyerek InvalidOperationException fırlatır. Yani SQLite'ta yazdığın, testlerden geçen kod SQL Server'a taşındığı gün patlar. transaction: tx'i her çağrıda yaz; bunu bir provider'ın iyi niyetine bırakma.

Isolation Level'a Kısa Bakış

Isolation level, aynı anda çalışan transaction'ların birbirinin yarım işini ne kadar görebileceğini belirler. BeginTransactionAsync'e bir IsolationLevel verebilirsin. SQLite'ta bu değer bir istek, emir değil:

await using (var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted))
{
    Console.WriteLine($"requested ReadCommitted, got {tx.IsolationLevel}");
    await conn.ExecuteAsync("update books set stock = 0 where id = 1", transaction: tx);

    await using var other = new SqliteConnection(cs);
    var seen = await other.ExecuteScalarAsync<int>("select stock from books where id = 1");
    Console.WriteLine($"other connection sees: {seen}");
}

try
{
    await using var tx = await conn.BeginTransactionAsync(IsolationLevel.Snapshot);
}
catch (Exception ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
requested ReadCommitted, got Serializable
other connection sees: 12
ArgumentException: The IsolationLevel 'Snapshot' is not supported.

Microsoft.Data.Sqlite istediğin seviyeyi bir alt sınır olarak alıp ya ReadUncommitted'a (yalnızca shared cache ile) ya da Serializable'a yükseltiyor; Snapshot ve Chaos'u hiç desteklemiyor. İkinci connection, commit edilmemiş 0 değerini değil eski 12'yi görüyor; dirty read yok. SQL Server'da ve PostgreSQL'de varsayılan seviye ReadCommitted'dır ve seçenekler gerçekten farklı davranır; bunların kilit ve versiyonlama ayrıntıları provider'a özgü konular, ilgili yazılarda yeri geldikçe döneceğiz.

SQLite'ın asıl kısıtını da bil: aynı anda yalnızca bir transaction yazabilir. Uzun süren bir yazma transaction'ı diğer yazanları bekletir, bekleme süresi dolarsa "database is locked" hatası gelir. Transaction'ı kısa tut: önce hesapla, sonra aç, yaz, commit et.

TransactionScope ve Async Notu

ADO.NET transaction'ına alternatif olarak System.Transactions.TransactionScope var. Scope açıldığında bir ambient transaction oluşur, o scope içinde açılan connection'lar otomatik olarak ona katılır, sen de transaction: parametresiyle uğraşmazsın. Ama async kodda bilmen gereken bir ayar var:

using System.Transactions;

try
{
    using var scope = new TransactionScope();
    await Task.Delay(10);
    scope.Complete();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
    await Task.Delay(10);
    Console.WriteLine($"ambient: {Transaction.Current?.TransactionInformation.Status}");
    scope.Complete();
}
A TransactionScope must be disposed on the same thread that it was created.
ambient: Active

Varsayılan TransactionScope ambient transaction'ı thread'e bağlı tutar. await sonrasında kod başka bir thread'de devam edince hem transaction kaybolur hem de dispose bu exception'ı fırlatır. TransactionScopeAsyncFlowOption.Enabled ambient transaction'ı async akışla birlikte taşır. Async kodda TransactionScope kullanacaksan bu parametre zorunlu.

Şimdi aynı şeyi SQLite ile deneyelim. Complete()'i bilerek çağırmıyoruz, yani her şeyin geri alınmasını bekliyoruz:

using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
    await using var conn = new SqliteConnection(cs);
    await conn.ExecuteAsync("update books set stock = 0 where id = 1");
    // scope.Complete() hiç çağrılmıyor
}

await using var check = new SqliteConnection(cs);
var stock = await check.ExecuteScalarAsync<int>("select stock from books where id = 1");
Console.WriteLine($"stock: {stock}");
stock: 0

Stok sıfırlandı ve öyle kaldı. Microsoft.Data.Sqlite henüz System.Transactions'ı desteklemiyor; dokümantasyonu bunu açıkça yazıyor ve ADO.NET transaction'larını öneriyor. Connection scope'a katılmıyor, update her zamanki gibi kendi başına commit ediliyor ve hiçbir hata da almıyorsun. SqlClient TransactionScope'a katılmayı destekler; ama provider'a göre değişen bir davranışa iş kuralı emanet etmek istemiyorsan Dapper ile BeginTransaction en öngörülebilir yol.

Sık Düşülen Tuzaklar

transaction: tx'i bir çağrıda unutmak. SQLite'ta sessiz geçer, SqlClient'ta exception fırlatır. Her çağrıda yaz; repository method'larına transaction'ı parametre olarak geçiriyorsan DbTransaction? tx = null imzası bunu unutmayı zorlaştırır.

Transaction içinde bir hatayı yakalayıp devam etmek. Hata veren statement'tan sonra transaction'ın durumu provider'a göre değişir; PostgreSQL'de transaction hata durumuna geçer ve rollback edilene kadar sonraki command'ları reddeder. Hata aldıysan transaction'ı bırak, baştan dene.

Transaction'ı açıkken ağ çağrısı, e-posta gönderimi ya da kullanıcı onayı beklemek. Transaction açık kaldığı sürece kilitler tutulur; SQLite'ta diğer yazanlar tamamen bekler. Dış dünyaya yapılan işler commit'ten önce ya da sonra yapılır, içinde değil.

TransactionScope'u async kodda TransactionScopeAsyncFlowOption.Enabled olmadan kullanmak ve SQLite'ta scope'un etkisiz olduğunu bilmemek.

Commit sonrası aynı transaction'ı tekrar kullanmaya çalışmak. Commit ya da rollback edilmiş bir transaction'ın işi bitmiştir; yeni iş için yeni transaction aç.

EF Core'dan gelenler için: SaveChanges değişiklikleri zaten tek bir transaction içinde yazar, çoğu senaryoda transaction'ı düşünmezsin bile. Dapper'da her Execute ayrı bir iş; birden fazla statement'ın bir bütün olması gerekiyorsa transaction'ı sen kurarsın. Bu, Dapper'ın "SQL'i ve sınırlarını sen çizersin" felsefesinin en somut örneği.

Küçük Bir Deneme

★☆☆ PlaceOrderAsync'teki üçüncü çağrıdan transaction: tx'i sil ve Ada'nın siparişini tekrar dene. Sonucun değişmediğini gör, sonra bunun neden sadece SQLite'a özgü bir şans olduğunu kendi cümlelerinle bir yoruma yaz.

★★☆ Sipariş birden fazla kalem alsın: PlaceOrderAsync'i (int BookId, int Quantity)[] alacak şekilde değiştir. İlk kalem başarılı, ikincisi stok yetmediği için başarısız olsun ve hiçbir kalemin yazılmadığını doğrula.

★★★ İki ayrı connection aç, connection string'e Default Timeout=1 ekle. Birinde transaction açıp bir update yap ve commit etme; diğerinden de bir update dene. Aldığın hata mesajını oku ve transaction'ı kısa tutmanın SQLite'ta neden bu kadar önemli olduğunu açıkla.

Şimdiye kadar hep tek tabloyu tek bir class'a eşledik. Oysa sipariş kalemi bir kitaba, kitap bir yazara bağlı ve bunları JOIN ile tek sorguda okumak istiyoruz. Bir sonraki yazıda multi-mapping ve splitOn ile tek row'dan birden fazla object üretecek, N+1 problemine de yakından bakacağız. Seri, Dapper'ın yeni sürümleri çıktıkça güncellenmeye devam edecek.