Transfer dana memotong saldo Budi dan menambah saldo Siti dalam dua perintah UPDATE. Bila listrik server padam di antara keduanya, tanpa transaksi pemotongan bisa tersimpan sementara penambahan tidak pernah terjadi — uang berpindah tanpa jejak penerima. Transaksi database menjamin sifat atomicity: rangkaian operasi berjalan seluruhnya atau tidak sama sekali. Spring membuat jaminan ini nyaris gratis lewat satu anotasi deklaratif — @Transactional — dan pada Chapter ini kita bedah cara kerjanya, aturan rollback, mode propagasi, serta level isolasi.

@Transactional Deklaratif #

Penanda transaksi ditempelkan pada method service — bukan repository — karena lapisan service yang menentukan batas unit of work bisnis. Berikut pola standarnya, ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@Service
public class LayananTransfer {

    private final RekeningRepository repo;

    public LayananTransfer(RekeningRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public void transfer(String dari, String ke, long jumlah) {
        Rekening sumber = repo.findByPemilik(dari).orElseThrow();
        Rekening tujuan = repo.findByPemilik(ke).orElseThrow();

        if (sumber.getSaldo() < jumlah) {
            throw new SaldoTidakCukupException("saldo " + dari + " tidak cukup");
        }
        sumber.setSaldo(sumber.getSaldo() - jumlah);
        tujuan.setSaldo(tujuan.getSaldo() + jumlah);
    }
}

Di balik layar, Spring membungkus method ini dengan AOP proxy: sebelum body berjalan, proxy membuka transaksi dan mengikatkan EntityManager ke thread; setelah body selesai tanpa kesalahan, proxy menjalankan COMMIT; bila exception tertentu lewat, proxy menjalankan ROLLBACK. Kode bisnis tidak menyebut begin, commit, atau rollback sama sekali — seluruh orkestrasi hidup di proxy.

Mode Propagasi #

Ketika method transaksional memanggil method transaksional lain, propagation menentukan bagaimana dua penanda itu berinteraksi. Dua mode terpenting:

PropagasiPerilakuKapan dipakai
REQUIRED (bawaan)ikut transaksi yang ada, atau buat baru bila tidak adahampir semua kasus
REQUIRES_NEWmenangguhkan transaksi luar, membuka transaksi terpisahlog audit yang wajib ter-commit meski transaksi utama gagal
SUPPORTS / NOT_SUPPORTEDikut bila ada / selalu tanpa transaksikasus khusus baca atau kerja non-DB
MANDATORY / NEVERwajib ada / wajib tidak adapenegakan arsitektur lewat exception

Contoh REQUIRES_NEW pada audit — ditampilkan apa adanya:

java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void catatAudit(String peristiwa) {
    auditRepository.save(new BarisAudit(peristiwa, Instant.now()));
}

Saat transaksi utama gagal dan me-roll, baris audit yang ditulis lewat REQUIRES_NEW tetap tersimpan — perilaku yang diinginkan untuk jejak peristiwa penting.

Level Isolasi #

Properti isolation mengatur seberapa banyak satu transaksi “melihat” transaksi lain yang berjalan bersamaan. Daftar lengkapnya: READ_UNCOMMITTED, READ_COMMITTED (bawaan banyak database), REPEATABLE_READ, dan SERIALIZABLE — dari paling longgar ke paling ketat, dengan trade-off konsistensi terhadap throughput. Anomali yang masing-masing level cegah dirangkum begini: dirty read (membaca perubahan yang belum di-commit), non-repeatable read (dua baca sama hasil beda karena UPDATE orang lain), dan phantom read (baris baru muncul di rentang yang sama). SERIALIZABLE menutup ketiganya dengan biaya locking terberat — karenanya pemilihan level harus sadar beban, bukan sekadar “yang paling aman”.

Simulasi Commit dan Rollback #

Perilaku commit/rollback layak dilihat pada state yang benar-benar berubah. Program Java murni berikut membangun “database” berupa map saldo, membungkus operasi dalam transaksi mini berbasis snapshot, dan memaksa satu kegagalan:

import java.util.HashMap;
import java.util.Map;

public class TransaksiMini {

    static class SaldoTidakCukupException extends RuntimeException {
        SaldoTidakCukupException(String pesan) { super(pesan); }
    }

    // "Tabel" sederhana: akun -> saldo.
    static class Dompet {
        final Map<String, Long> saldo = new HashMap<>();

        void ubah(String akun, long delta) {
            saldo.merge(akun, delta, Long::sum);
        }

        long cek(String akun) {
            return saldo.getOrDefault(akun, 0L);
        }

        Dompet snapshot() {
            Dompet salinan = new Dompet();
            salinan.saldo.putAll(saldo);
            return salinan;
        }

        void pulihkan(Dompet salinan) {
            saldo.clear();
            saldo.putAll(salinan.saldo);
        }
    }

    // Wrapper @Transactional mini: snapshot awal, commit bila sukses,
    // rollback penuh bila RuntimeException lewat.
    static void transaksi(String label, Dompet dompet, Runnable aksi) {
        System.out.println("TRANSAKSI [" + label + "] mulai");
        Dompet salinan = dompet.snapshot();
        try {
            aksi.run();
            System.out.println("  -> COMMIT");
        } catch (RuntimeException e) {
            dompet.pulihkan(salinan);
            System.out.println("  -> ROLLBACK: " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        Dompet dompet = new Dompet();
        dompet.ubah("budi", 100_000);
        dompet.ubah("siti", 50_000);
        System.out.println("saldo awal  : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
        System.out.println();

        transaksi("transfer sukses", dompet, () -> {
            dompet.ubah("budi", -30_000);
            dompet.ubah("siti", 30_000);
        });
        System.out.println("  saldo     : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
        System.out.println();

        transaksi("transfer gagal", dompet, () -> {
            dompet.ubah("budi", -500_000);
            if (dompet.cek("budi") < 0) {
                throw new SaldoTidakCukupException("saldo budi tidak cukup");
            }
            dompet.ubah("siti", 500_000);
        });
        System.out.println("  saldo     : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
        System.out.println();

        transaksi("transfer kecil setelah rollback", dompet, () -> {
            dompet.ubah("siti", -10_000);
            dompet.ubah("budi", 10_000);
        });
        System.out.println("  saldo     : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
    }
}

Output yang Dihasilkan:

text
saldo awal  : budi=100000 siti=50000

TRANSAKSI [transfer sukses] mulai
  -> COMMIT
  saldo     : budi=70000 siti=80000

TRANSAKSI [transfer gagal] mulai
  -> ROLLBACK: saldo budi tidak cukup
  saldo     : budi=70000 siti=80000

TRANSAKSI [transfer kecil setelah rollback] mulai
  -> COMMIT
  saldo     : budi=80000 siti=70000

Mari kita bedah transaksi mini tersebut:

  1. transaksi(label, dompet, aksi): method ini berperan sebagai proxy @Transactional. Baris dompet.snapshot() menyalin seluruh state sebelum aksi berjalan — padanan BEGIN yang menandai titik pemulihan.
  2. catch (RuntimeException e): penangkapan ini meniru aturan rollback Spring — hanya RuntimeException yang memicu pemulihan. dompet.pulihkan(salinan) menimpa seluruh state dengan salinan awal, sehingga efek ubah("budi", -500_000) yang sudah telanjur berjalan dibatalkan sekaligus.
  3. Transaksi kedua: penurunan saldo Budi terjadi lebih dulu, baru pengecekan melempar SaldoTidakCukupException. Output membuktikan saldo kedua akun kembali ke nilai sebelum transaksi — atomicity bekerja meski satu operasi sudah berjalan.
  4. Transaksi ketiga: berjalan setelah rollback dan berhasil — state yang dipulihkan bukan state rusak, melainkan titik awal yang bersih. Urutan COMMIT/ROLLBACK tercetak eksplisit, sesuatu yang di aplikasi nyata tersembunyi di dalam proxy.

Satu peringatan klasik penutup: @Transactional tidak berfungsi pada pemanggilan method dari dalam kelas yang sama — proxy hanya berada di jalur panggilan dari luar. Keluarkan method transaksional ke bean terpisah, atau injeksikan proxy kelas itu sendiri (ObjectProvider), dan batas transaksi akan dihormati kembali. Dengan data dan transaksi aman di tangan, kita melangkah ke perbatasan yang sama seriusnya: keamanan aplikasi dengan Spring Security.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

TransaksiMini.java

Unduh berkas
java
import java.util.HashMap;
import java.util.Map;

public class TransaksiMini {

    static class SaldoTidakCukupException extends RuntimeException {
        SaldoTidakCukupException(String pesan) { super(pesan); }
    }

    // "Tabel" sederhana: akun -> saldo.
    static class Dompet {
        final Map<String, Long> saldo = new HashMap<>();

        void ubah(String akun, long delta) {
            saldo.merge(akun, delta, Long::sum);
        }

        long cek(String akun) {
            return saldo.getOrDefault(akun, 0L);
        }

        Dompet snapshot() {
            Dompet salinan = new Dompet();
            salinan.saldo.putAll(saldo);
            return salinan;
        }

        void pulihkan(Dompet salinan) {
            saldo.clear();
            saldo.putAll(salinan.saldo);
        }
    }

    // Wrapper @Transactional mini: snapshot awal, commit bila sukses,
    // rollback penuh bila RuntimeException lewat.
    static void transaksi(String label, Dompet dompet, Runnable aksi) {
        System.out.println("TRANSAKSI [" + label + "] mulai");
        Dompet salinan = dompet.snapshot();
        try {
            aksi.run();
            System.out.println("  -> COMMIT");
        } catch (RuntimeException e) {
            dompet.pulihkan(salinan);
            System.out.println("  -> ROLLBACK: " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        Dompet dompet = new Dompet();
        dompet.ubah("budi", 100_000);
        dompet.ubah("siti", 50_000);
        System.out.println("saldo awal  : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
        System.out.println();

        transaksi("transfer sukses", dompet, () -> {
            dompet.ubah("budi", -30_000);
            dompet.ubah("siti", 30_000);
        });
        System.out.println("  saldo     : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
        System.out.println();

        transaksi("transfer gagal", dompet, () -> {
            dompet.ubah("budi", -500_000);
            if (dompet.cek("budi") < 0) {
                throw new SaldoTidakCukupException("saldo budi tidak cukup");
            }
            dompet.ubah("siti", 500_000);
        });
        System.out.println("  saldo     : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
        System.out.println();

        transaksi("transfer kecil setelah rollback", dompet, () -> {
            dompet.ubah("siti", -10_000);
            dompet.ubah("budi", 10_000);
        });
        System.out.println("  saldo     : budi=" + dompet.cek("budi")
                + " siti=" + dompet.cek("siti"));
    }
}