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:
- Saat Spring menginisialisasi bean
UserService, Spring membuat wrapper atau proxy di sekitar instance asli. - Ketika bean lain menginjeksi
UserServicedan memanggil method publiknya, panggilan tersebut melewati proxy terlebih dahulu. - Proxy mengeksekusi
TransactionInterceptoruntuk memulai transaksi, memanggil target asli, lalu memutuskan untuk melakukanCOMMITatauROLLBACKberdasarkan 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
thissaat memanggilthis.createUserAndAudit(). - Pemanggilan via
thismemotong (bypass) keberadaan proxy.TransactionInterceptortidak 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 penggunaanAopContext.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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!