Tiga gaya query di bab lalu semuanya mengasumsikan satu operasi database yang berdiri sendiri. Tapi dalam praktiknya, satu alur bisnis sering perlu melakukan beberapa operasi database sekaligus, dan seluruhnya harus berhasil bersama-sama — atau gagal bersama-sama. Itulah topik bab ini: transaction management lewat @Transactional.
Konsep @Transactional: all-or-nothing
Sebuah database transaction adalah sekelompok operasi (insert, update, delete) yang diperlakukan sebagai satu unit kerja tak terpisahkan. Prinsipnya sederhana: semua berhasil, atau semua dibatalkan — tidak ada kondisi di tengah di mana sebagian data tersimpan dan sebagian lagi tidak.
Di Spring, cara paling umum menandai sebuah method sebagai satu transaction adalah anotasi @Transactional. Method yang diberi anotasi ini akan dibungkus Spring dalam satu transaction database: kalau method selesai secara normal, transaction di-commit (semua perubahan permanen); kalau method melempar exception (unchecked, secara default), transaction di-rollback — seluruh perubahan yang sudah "terjadi" di dalam method itu dibatalkan, seolah-olah tidak pernah dijalankan sama sekali.
createOrdersBulk — satu transaction untuk banyak order
Ini bagian yang jadi fokus utama kita, dari OrderService.kt:
// @Transactional membungkus seluruh loop dalam SATU transaksi database:
// kalau salah satu request gagal (mis. quantity tidak valid), seluruh order
// yang sudah "tersimpan" sebelumnya di request yang sama ikut di-rollback,
// bukan cuma yang gagal.
@Transactional
fun createOrdersBulk(requests: List<CreateOrderRequest>): List<Order> =
requests.map { createOrder(it) }
Dan createOrder yang dipanggil di dalam loop-nya:
fun createOrder(request: CreateOrderRequest): Order {
require(request.quantity >= 1) { "quantity minimal 1" }
// ...
return orderRepository.save(order)
}
require(request.quantity >= 1) akan melempar IllegalArgumentException kalau quantity kurang dari 1 — ini validasi bisnis sederhana yang jadi pemicu skenario rollback yang akan kita buktikan.
Sekarang bandingkan dua skenario. Tanpa @Transactional, kalau createOrdersBulk memanggil createOrder tiga kali secara berurutan dan panggilan ketiga gagal, dua order pertama sudah terlanjur ter-commit ke database masing-masing saat save() dipanggil — karena tanpa @Transactional, setiap save() (lewat JpaRepository) berjalan dalam transaction-nya sendiri-sendiri yang langsung di-commit begitu selesai. Hasilnya data setengah jalan: dua order tersimpan, satu gagal, padahal secara bisnis batch ini seharusnya dianggap gagal seluruhnya.
Dengan @Transactional di createOrdersBulk, seluruh pemanggilan createOrder di dalam loop .map { } berjalan di dalam satu transaction yang sama. Ketiga save() memang tetap dieksekusi ke database, tapi belum benar-benar permanen (belum di-commit) sampai method createOrdersBulk selesai seluruhnya tanpa error. Begitu item ketiga melempar IllegalArgumentException, Spring mendeteksi exception ini keluar dari method yang bertanda @Transactional, lalu memerintahkan rollback — dua save() yang tadi "sudah jalan" ikut dibatalkan. Hasil akhirnya: nol order tersimpan, bukan dua.
Membuktikannya lewat integration test asli
Klaim seperti ini gampang ditulis di komentar kode, tapi mudah juga salah tanpa disadari — misalnya kalau ternyata ada bug di mana rollback tidak benar-benar terjadi. Order Service membuktikannya lewat integration test yang jalan lawan PostgreSQL asli (via Testcontainers, bukan mock atau H2 in-memory), di OrderTransactionIntegrationTest.kt:
@SpringBootTest
class OrderTransactionIntegrationTest : AbstractIntegrationTest() {
@Autowired
lateinit var orderService: OrderService
@Autowired
lateinit var orderRepository: OrderRepository
@Test
fun `bulk create harus rollback seluruhnya kalau salah satu item gagal`() {
val requests = listOf(
CreateOrderRequest("Andi Wijaya", "Headset", 1, BigDecimal("300000")),
CreateOrderRequest("Andi Wijaya", "Webcam", 1, BigDecimal("450000")),
CreateOrderRequest("Andi Wijaya", "Speaker", 0, BigDecimal("200000")) // quantity 0 -> ditolak
)
assertThrows(IllegalArgumentException::class.java) {
orderService.createOrdersBulk(requests)
}
val savedOrders = orderRepository.findByCustomerNameContainingIgnoreCase("Andi Wijaya")
assertEquals(0, savedOrders.size, "2 order pertama seharusnya ikut rollback, bukan tetap tersimpan")
}
@Test
fun `bulk create berhasil menyimpan semua item kalau seluruhnya valid`() {
val requests = listOf(
CreateOrderRequest("Rina Marlina", "Headset", 1, BigDecimal("300000")),
CreateOrderRequest("Rina Marlina", "Webcam", 1, BigDecimal("450000"))
)
orderService.createOrdersBulk(requests)
val savedOrders = orderRepository.findByCustomerNameContainingIgnoreCase("Rina Marlina")
assertEquals(2, savedOrders.size)
}
}
Perhatikan kenapa test ini genuinely meyakinkan, bukan sekadar "assert lalu percaya":
- Test pertama mengirim 3 request untuk customer yang sama ("Andi Wijaya"), di mana item ketiga sengaja punya
quantity = 0— nilai yang pasti ditolak olehrequire(request.quantity >= 1). Test ini tidak berhenti diassertThrowssaja (yang cuma membuktikan exception-nya benar-benar dilempar) — ia melangkah lebih jauh dengan query ulang ke database lewatorderRepository.findByCustomerNameContainingIgnoreCase("Andi Wijaya"), dan memastikan hasilnya nol baris. Ini pembuktian langsung terhadap isi database sungguhan, bukan asumsi berdasarkan perilaku method saja. - Test kedua adalah kontrolnya: skenario di mana semua item valid, memastikan
@Transactionaltidak berarti "tidak ada yang pernah tersimpan" — begitu seluruh batch berhasil tanpa error, ketiganya (dalam kasus ini keduanya) benar-benar ter-commit dan bisa di-query kembali.
Kedua test ini sama pentingnya — tanpa test kedua, kita tidak tahu apakah rollback di test pertama itu memang karena mekanisme transaction, atau jangan-jangan createOrdersBulk memang tidak pernah menyimpan apa pun sama sekali dalam skenario apa pun.
AbstractIntegrationTest, yang di-extend oleh test class ini, menyalakan container PostgreSQL asli lewat Testcontainers dan menyambungkan datasource aplikasi ke container tersebut lewat @DynamicPropertySource — bukan lewat application-test.yml. Kita akan bahas detail pola ini lebih dalam saat masuk Fase 3 (Testing); untuk sekarang, cukup pahami bahwa kedua test di atas benar-benar berbicara dengan database PostgreSQL yang hidup, bukan simulasi.
Endpoint POST /bulk
OrderController mengekspos method yang kita bahas lewat endpoint:
@PostMapping("/bulk")
fun createOrdersBulk(@RequestBody requests: List<CreateOrderRequest>): ResponseEntity<List<OrderResponse>> {
val orders = orderService.createOrdersBulk(requests)
return ResponseEntity.status(HttpStatus.CREATED).body(orders.map { it.toResponse() })
}
Kalau salah satu order di dalam body request gagal validasi, endpoint ini akan merespons error (ditangani oleh error handler dari Fase 1) dan — berkat @Transactional yang sudah kita bahas — tidak ada order sama sekali dari batch itu yang nyangkut di database, memberi klien kepastian: request ini benar-benar gagal total, bukan gagal sebagian tanpa pemberitahuan jelas mana yang berhasil.
Kita sudah menutup topik inti persistence: koneksi, entity, migration, query, dan transaction. Yang tersisa adalah bagaimana seluruh konfigurasi ini berbeda-beda secara aman antar environment — dev, test, dan production — topik bab terakhir Fase 2 berikutnya.
Bagian dari Series: Kotlin Backend Microservice - Belajar Fundamental sampai Microservice Nyata
Belajar Kotlin + Spring Boot dari fundamental bahasa (null safety, OOP, coroutines) sampai membangun sistem microservice sungguhan - REST API, Postgre...
Lihat Series Lengkap