Menyimpan data sekunder seperti audit trail, log riwayat, atau sinkronisasi metrik sering kali diintegrasikan menggunakan @TransactionalEventListener Spring Boot. Namun, masalah umum yang kerap ditemui adalah silent failure: method listener terpanggil, log aplikasi tercetak normal, tidak ada Exception yang dilempar, tetapi entitas yang disimpan menggunakan Spring Data JPA sama sekali tidak pernah masuk ke dalam database.

Gejala Masalah: Silent Failure pada Fase AFTER_COMMIT

Secara default, @TransactionalEventListener berjalan pada fase TransactionPhase.AFTER_COMMIT. Gejala yang muncul pada skenario ini sangat konsisten:

  • Event listener terpicu tepat setelah transaksi pemanggil (caller) selesai melakukan commit.
  • Proses di dalam method listener dieksekusi dari awal hingga akhir tanpa hambatan.
  • Database tidak mencatat row baru dari operasi save() di dalam listener.
  • Log console tidak menampilkan error atau rollback warning dari Spring maupun database engine.

Kode Reproduksi Masalah

Kode di bawah ini mereproduksi bug tersebut secara langsung. Ketika order dibuat, event dipublikasikan untuk mencatat audit trail.

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    public OrderService(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public Order createOrder(String product, BigDecimal amount) {
        Order order = orderRepository.save(new Order(product, amount));
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
        return order;
    }
}

Listener bermasalah yang mencoba menyimpan entitas audit:

@Component
public class AuditEventListener {
    private final AuditRepository auditRepository;
    private static final Logger log = LoggerFactory.getLogger(AuditEventListener.class);

    public AuditEventListener(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    // MASALAH: Default phase adalah AFTER_COMMIT tanpa transaksi baru
    @TransactionalEventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        log.info("Processing audit for order: {}", event.orderId());
        
        AuditLog audit = new AuditLog("ORDER_CREATED", event.orderId());
        auditRepository.save(audit);
        
        log.info("Audit log saved successfully"); // Tercetak di log, tetapi row tidak tersimpan di DB
    }
}

Analisis Root Cause: Siklus Transaksi dan Hibernate Flush

Penyebab utama dari masalah ini berkaitan dengan siklus hidup transaksi Spring dan mekanisme flush JPA/Hibernate:

  1. Fase AFTER_COMMIT: Listener dipanggil setelah transaksi utama berhasil melakukan commit() ke database fisik. Koneksi database utama telah ditutup atau dikembalikan ke connection pool.
  2. Status Transaksi Aktif: Karena dieksekusi pada thread yang sama persis dengan caller, konteks transaksi Spring yang tersisa masih dianggap aktif tetapi berada dalam status completed (atau sinkronisasi transaksi dinonaktifkan).
  3. Propagation Behavior: Default method save() milik SimpleJpaRepository dianotasikan dengan @Transactional(propagation = Propagation.REQUIRED). Karena Spring melihat masih ada jejak konteks transaksi lama (yang sudah selesai), method ini tidak membuat transaksi baru.
  4. Hibernate Session Flush Batal: Hibernate mengantongi operasi INSERT di dalam persistence context level 1 cache. Tanpa adanya trigger commit transaksi fisik yang baru, Hibernate tidak pernah memicu flush() ke database. Persistence context kemudian langsung dibuang saat thread selesai, menghapus seluruh perubahan secara senyap.

Langkah Diagnosis Menggunakan Logging

Untuk memverifikasi perilaku tersebut, aktifkan detail logging transaksi Spring dan Hibernate pada application.properties:

logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.hibernate.SQL=DEBUG

Ketika kode bermasalah dieksekusi, periksa urutan log transaksi:

o.s.t.i.TransactionInterceptor : Getting transaction for [OrderService.createOrder]
o.s.t.i.TransactionInterceptor : Completing transaction for [OrderService.createOrder]
o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
AuditEventListener : Processing audit for order: 101
AuditEventListener : Audit log saved successfully

Perhatikan bahwa setelah pesan Initiating transaction commit dari OrderService, tidak pernah ada log inisiasi transaksi baru untuk auditRepository.save(), dan tidak ada statement insert into audit_log yang dicetak oleh Hibernate.

Solusi 1: Gunakan Propagation.REQUIRES_NEW (Sinkron)

Jika operasi write harus dijalankan secara sinkron pada thread yang sama (misal harus selesai sebelum response HTTP dikembalikan), paksa pembuatan transaksi fisik baru yang independen menggunakan Propagation.REQUIRES_NEW.

@Component
public class AuditEventListener {
    private final AuditRepository auditRepository;

    public AuditEventListener(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void onOrderCreated(OrderCreatedEvent event) {
        AuditLog audit = new AuditLog("ORDER_CREATED", event.orderId());
        auditRepository.save(audit);
    }
}

Catatan: Membuka transaksi REQUIRES_NEW akan meminjam koneksi database kedua dari connection pool (HikariCP) pada thread yang sama. Pastikan pool size cukup agar tidak terjadi pool deadlocking pada traffic tinggi.

Solusi 2: Gunakan @Async (Asinkron / Non-Blocking)

Untuk operasi audit atau notifikasi yang tidak perlu membebani waktu eksekusi request utama, gunakan @Async bersama @Transactional. Dengan @Async, event diproses di thread pool terpisah yang sepenuhnya bebas dari konteks transaksi thread utama.

@Configuration
@EnableAsync
public class AsyncConfig {
    // Konfigurasi TaskExecutor sesuai kebutuhan sistem
}
@Component
public class AsyncAuditEventListener {
    private final AuditRepository auditRepository;

    public AsyncAuditEventListener(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional
    public void onOrderCreated(OrderCreatedEvent event) {
        AuditLog audit = new AuditLog("ORDER_CREATED", event.orderId());
        auditRepository.save(audit);
    }
}

Karena berjalan di thread terpisah, @Transactional default (REQUIRED) akan langsung membuka transaksi baru tanpa perlu REQUIRES_NEW.

Verifikasi dengan Integration Test

Buat integration test menggunakan @SpringBootTest untuk memastikan bahwa persistence terjadi secara nyata.

@SpringBootTest
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private AuditRepository auditRepository;

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void setUp() {
        auditRepository.deleteAll();
        orderRepository.deleteAll();
    }

    @Test
    void shouldPersistAuditLogWhenOrderCreated() {
        // Given
        BigDecimal amount = new BigDecimal("150000.00");

        // When
        Order createdOrder = orderService.createOrder("Laptop Desk", amount);

        // Then
        assertNotNull(createdOrder.getId());
        assertEquals(1, orderRepository.count());
        
        // Verifikasi audit log berhasil disimpan ke database fisik
        List<AuditLog> audits = auditRepository.findByOrderId(createdOrder.getId());
        assertEquals(1, audits.size());
        assertEquals("ORDER_CREATED", audits.get(0).getAction());
    }
}

Panduan Pemilihan Solusi

  • Gunakan @Transactional(propagation = Propagation.REQUIRES_NEW) jika kegagalan penulisan audit harus melempar error langsung ke pemanggil atau membutuhkan integritas urutan sekuensial.
  • Gunakan @Async + @Transactional jika operasi penulisan sekunder bersifat pelengkap (telemetri, email log, audit non-kritis) agar throughput API utama tetap optimal.