Database Transaction: Kapan Wajib Dipakai

Salah satu bug paling menyebalkan yang pernah saya temui di sistem yang menyentuh transaksi (dalam arti bisnis, bukan database) adalah data yang "setengah jadi": satu tabel sudah terupdate, tabel lain yang seharusnya ikut berubah malah tidak. Order tercatat tapi stok tidak berkurang. Pembayaran tercatat tapi saldo tidak ikut disesuaikan. Ini bukan soal query yang salah — query-nya benar semua kalau dijalankan satu-satu. Masalahnya ada di tengah, saat proses gagal di antara langkah kedua dan ketiga, lalu tidak ada mekanisme yang membatalkan langkah pertama. Di sinilah database transaction ada perannya, dan menurut saya ini salah satu konsep dasar yang paling sering diremehkan developer yang baru belajar Laravel.

Kapan operasi wajib dibungkus transaction

Aturan praktis yang saya pakai: kalau sebuah proses bisnis melibatkan lebih dari satu write ke database, dan kedua write itu harus sama-sama berhasil atau sama-sama gagal (tidak boleh salah satu saja yang jalan), itu kandidat kuat untuk transaction. Contoh klasik yang hampir selalu muncul di sistem transaksional apa pun:

  • Membuat order, lalu mengurangi stok barang terkait.
  • Mencatat pembayaran, lalu mengubah status invoice.
  • Memindahkan saldo dari satu akun ke akun lain (debit di satu baris, kredit di baris lain).
  • Membuat record utama, lalu beberapa record anak (misalnya order dan order_items) yang harus konsisten jumlahnya.

Tanpa transaction, kalau proses gagal di tengah jalan (exception, koneksi database terputus, request timeout), yang tersisa adalah data yang secara logis tidak mungkin terjadi — order ada tapi stoknya tidak pernah berkurang. Di Laravel, cara paling umum membungkus ini sederhana:

DB::transaction(function () use ($orderData) {
    $order = Order::create($orderData);

    foreach ($orderData['items'] as $item) {
        $product = Product::lockForUpdate()->find($item['product_id']);
        $product->decrement('stock', $item['qty']);
        $order->items()->create($item);
    }
});

Kalau ada exception di mana pun di dalam closure itu, Laravel otomatis melakukan rollback ke semua perubahan yang sudah terjadi di dalamnya. Tidak ada state setengah jadi yang tersisa. Saya juga menambahkan lockForUpdate() di contoh ini karena kalau ada dua request yang bersamaan mengurangi stok produk yang sama, tanpa row lock keduanya bisa membaca angka stok yang sama sebelum saling menimpa update masing-masing — race condition klasik yang transaction saja tidak otomatis mencegah.

Jangan sampai transaction jadi terlalu panjang

Bagian yang sering luput justru soal seberapa lama transaction itu terbuka, bukan soal apakah dipakai atau tidak. Selama transaction berjalan, baris-baris yang disentuh (apalagi dengan lock eksplisit) tertahan sampai transaction ditutup — dan setiap request lain yang butuh baris yang sama harus antre. Kalau di dalam transaction ada operasi yang lambat atau tidak dapat diprediksi waktunya, seperti panggilan ke API eksternal, pengiriman email, atau pemrosesan file besar, semua request lain yang menunggu baris yang sama ikut tertahan selama itu juga. Dalam kasus terburuk, ini bisa memicu deadlock atau membuat seluruh sistem terasa lambat padahal query-nya sendiri cepat.

Prinsip yang saya pegang: transaction hanya untuk operasi database yang saling bergantung secara atomik, bukan tempat menaruh semua logika proses. Panggilan ke layanan eksternal sebaiknya dilakukan sebelum transaction dibuka (kalau hasilnya dibutuhkan sebagai input) atau setelah transaction commit (kalau itu efek samping, seperti mengirim notifikasi). Sebagai ilustrasi:

$order = DB::transaction(function () use ($data) {
    $order = Order::create($data);
    $order->items()->createMany($data['items']);
    return $order;
});

// dilakukan setelah transaction commit, bukan di dalamnya
NotifyOrderCreated::dispatch($order);

Dengan begitu, kalau layanan notifikasi lambat atau bahkan gagal, itu tidak menahan lock database sama sekali — cukup ditangani lewat queue job yang punya retry-nya sendiri.

Takeaway singkat

Pakai transaction ketika beberapa write harus konsisten sebagai satu unit, dan jaga isinya tetap ramping — hanya operasi database murni, tanpa panggilan eksternal atau proses berat di dalamnya. Transaction yang benar bukan yang paling panjang mencakup semua langkah proses, tapi yang paling pendek sambil tetap menjamin atomisitas yang benar-benar dibutuhkan.