Anotasi @Transactional pada Spring Boot sering kali memberikan ilusi bahwa penanganan transaksi database bekerja secara otomatis di setiap baris kode. Namun, masalah umum terjadi ketika mutasi data tetap tersimpan ke database (ter-commit) meskipun RuntimeException dilempar, tanpa ada pesan kegagalan dari PlatformTransactionManager.

Gejala ini hampir selalu berakar pada satu masalah: self-invocation (pemanggilan metode internal dalam class yang sama), yang melewati lapisan proxy Spring AOP.

Gejala Masalah: Data Tersimpan Meski Exception Terjadi

Perhatikan implementasi layanan registrasi pengguna dan audit log berikut. Skenario bisnis mengharuskan pembatalan pembuatan user jika pencatatan audit log gagal:

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public void registerUser(String username, String email) {
        // Pemanggilan internal (self-invocation)
        this.createUserAndAudit(username, email);
    }

    @Transactional
    public void createUserAndAudit(String username, String email) {
        User user = new User(username, email);
        userRepository.save(user);

        // Simulasi runtime exception
        if (email.endsWith("@invalid.com")) {
            throw new IllegalArgumentException("Domain email tidak valid.");
        }
    }
}

Saat method registerUser dipanggil dengan email yang memicu exception, IllegalArgumentException tetap terlempar ke caller. Namun, entitas User tetap tersimpan di tabel database. Tidak ada eksekusi perintah SQL ROLLBACK di log transaksi.

Root Cause: Batasan Arsitektur Dynamic Proxy Spring AOP

Spring mengimplementasikan declarative transaction management menggunakan Spring AOP via Dynamic Proxy (JDK Dynamic Proxy atau CGLIB). Secara default:

  1. Saat Spring menginisialisasi bean UserService, Spring membuat wrapper atau proxy di sekitar instance asli.
  2. Ketika bean lain menginjeksi UserService dan memanggil method publiknya, panggilan tersebut melewati proxy terlebih dahulu.
  3. Proxy mengeksekusi TransactionInterceptor untuk memulai transaksi, memanggil target asli, lalu memutuskan untuk melakukan COMMIT atau ROLLBACK berdasarkan hasil eksekusi.

Pada kasus self-invocation, alur tersebut rusak:

  • Caller eksternal memanggil registerUser(). Method ini tidak memiliki anotasi @Transactional, sehingga proxy hanya meneruskan panggilan langsung ke instance target.
  • Di dalam instance target, eksekusi berjalan langsung pada referensi this saat memanggil this.createUserAndAudit().
  • Pemanggilan via this memotong (bypass) keberadaan proxy. TransactionInterceptor tidak pernah diaktifkan, konteks transaksi tidak pernah dibuat, dan operasi database berjalan dalam mode auto-commit default koneksi database.

Strategi Perbaikan

1. Ekstraksi Method Transaksional ke Service Terpisah (Pendekatan Modular)

Pendekatan paling bersih sesuai prinsip Single Responsibility Principle (SRP) adalah memindahkan logika transaksional ke komponen terpisah agar interaksi selalu melewati proxy Spring.

@Service
public class UserRegistrationService {

    private final UserTransactionalService transactionalService;

    public UserRegistrationService(UserTransactionalService transactionalService) {
        this.transactionalService = transactionalService;
    }

    public void registerUser(String username, String email) {
        // Panggilan melewati proxy bean UserTransactionalService
        transactionalService.createUserAndAudit(username, email);
    }
}

@Service
public class UserTransactionalService {

    private final UserRepository userRepository;

    public UserTransactionalService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public void createUserAndAudit(String username, String email) {
        User user = new User(username, email);
        userRepository.save(user);

        if (email.endsWith("@invalid.com")) {
            throw new IllegalArgumentException("Domain email tidak valid.");
        }
    }
}

2. Penggunaan TransactionTemplate secara Programatik

Jika restrukturisasi class tidak diinginkan atau batas transaksi mencakup blok kode tertentu di dalam method, gunakan TransactionTemplate. Pendekatan ini tidak bergantung pada proxy AOP.

@Service
public class UserService {

    private final UserRepository userRepository;
    private final TransactionTemplate transactionTemplate;

    public UserService(UserRepository userRepository, PlatformTransactionManager transactionManager) {
        this.userRepository = userRepository;
        this.transactionTemplate = new TransactionTemplate(transactionManager);
    }

    public void registerUser(String username, String email) {
        transactionTemplate.executeWithoutResult(status -> {
            User user = new User(username, email);
            userRepository.save(user);

            if (email.endsWith("@invalid.com")) {
                throw new IllegalArgumentException("Domain email tidak valid.");
            }
        });
    }
}

3. Self-Injection via ObjectProvider

Jika refactoring arsitektur belum memungkinkan karena dependensi internal yang kompleks, injeksikan bean ke dalam dirinya sendiri. Gunakan ObjectProvider untuk menghindari circular dependency saat inisialisasi context.

@Service
public class UserService {

    private final UserRepository userRepository;
    private final ObjectProvider<UserService> selfProvider;

    public UserService(UserRepository userRepository, ObjectProvider<UserService> selfProvider) {
        this.userRepository = userRepository;
        this.selfProvider = selfProvider;
    }

    public void registerUser(String username, String email) {
        // Mengambil referensi proxy dari ApplicationContext
        UserService proxy = selfProvider.getIfAvailable();
        if (proxy != null) {
            proxy.createUserAndAudit(username, email);
        } else {
            this.createUserAndAudit(username, email);
        }
    }

    @Transactional
    public void createUserAndAudit(String username, String email) {
        User user = new User(username, email);
        userRepository.save(user);

        if (email.endsWith("@invalid.com")) {
            throw new IllegalArgumentException("Domain email tidak valid.");
        }
    }
}
Catatan: Hindari penggunaan AopContext.currentProxy(). Pendekatan tersebut memerlukan konfigurasi eksplisit @EnableAspectJAutoProxy(exposeProxy = true) dan memasang coupling ketat antara kode bisnis dengan implementasi internal Spring AOP.

Verifikasi Rollback dengan Integration Test

Gunakan pengujian integrasi dengan context Spring penuh untuk memverifikasi apakah batas transaksi dan rollback bekerja dengan benar saat exception terjadi.

@SpringBootTest
class UserServiceIntegrationTest {

    @Autowired
    private UserRegistrationService registrationService;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void shouldRollbackWhenExceptionIsThrown() {
        String username = "john_doe";
        String invalidEmail = "[email protected]";

        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            registrationService.registerUser(username, invalidEmail);
        });

        // Data tidak boleh ada di database jika rollback berhasil dieksekusi
        Optional<User> user = userRepository.findByUsername(username);
        Assertions.assertTrue(user.isEmpty(), "Database harus bersih setelah rollback.");
        Assertions.assertEquals(0, userRepository.count());
    }
}

Panduan Pemilihan Solusi

  • Gunakan Ekstraksi Service untuk sebagian besar kasus desain baru atau refactoring berkelanjutan. Ini mematuhi prinsip desain objek yang baik dan memisahkan domain boundary secara tegas.
  • Gunakan TransactionTemplate untuk kontrol transaksi tingkat granular atau saat bekerja di lingkungan multi-threading di mana proxy thread-local tidak dapat menjangkau sub-thread.
  • Gunakan Self-Injection via ObjectProvider hanya sebagai solusi sementara (hotfix) pada legacy code yang memiliki risiko regresi tinggi jika dirombak total.