Transfer saldo adalah dua tulisan yang harus terjadi bersama: rekening asal berkurang, rekening tujuan bertambah. Jika aplikasi berhenti di antara keduanya, uang berpindah tanpa jejak atau hilang tanpa penerima. Transaksi MongoDB menyelesaikan ini dengan jaminan all-or-nothing, dan Mongoose menyediakan pembungkusnya: mongoose.connection.transaction(). Bab ini menguji transfer yang sah dan transfer yang ditolak, memastikan rollback benar-benar terjadi, lalu melihat pola manual dengan startSession() untuk kasus yang butuh kendali lebih.

connection.transaction() #

Method ini adalah pembungkus withTransaction() milik driver yang juga terhubung dengan pelacakan perubahan Mongoose. Callback-nya menerima satu session, dan aturan mainnya sederhana: callback selesai normal → transaksi di-commit; callback melempar galat → transaksi dibatalkan seluruhnya:

js
// transfer-saldo.js — transaksi multi-document dengan connection.transaction()
const mongoose = require("mongoose")

const uri =
  process.env.MONGODB_URI ??
  "mongodb://127.0.0.1:27017/?directConnection=true"

async function main() {
  await mongoose.connect(uri, { dbName: "latihan_node_transaksi" })
  await mongoose.connection.db.dropDatabase()

  const rekeningSchema = new mongoose.Schema({
    nama: { type: String, required: true },
    saldo: { type: Number, required: true, min: 0 },
  })
  const Rekening = mongoose.model("Rekening", rekeningSchema)

  await Rekening.create({ nama: "Budi", saldo: 100000 })
  await Rekening.create({ nama: "Siti", saldo: 50000 })

  async function transfer(dariNama, keNama, jumlah) {
    await mongoose.connection.transaction(async (session) => {
      const dari = await Rekening.findOne({ nama: dariNama }).session(session)
      const ke = await Rekening.findOne({ nama: keNama }).session(session)

      if (dari.saldo < jumlah) {
        throw new Error(`Saldo ${dari.nama} tidak cukup (${dari.saldo} < ${jumlah})`)
      }

      dari.saldo -= jumlah
      ke.saldo += jumlah
      await dari.save({ session })
      await ke.save({ session })
    })
  }

  // 1) transfer yang sah
  await transfer("Budi", "Siti", 25000)
  console.log("1. setelah transfer Budi -> Siti sebesar 25000:")
  for (const r of await Rekening.find().sort({ nama: 1 })) {
    console.log("   ", r.nama, ":", r.saldo)
  }

  // 2) transfer yang melanggar aturan -> seluruh perubahan dibatalkan
  try {
    await transfer("Siti", "Budi", 999999)
  } catch (err) {
    console.log("2. transaksi dibatalkan:", err.message)
  }
  console.log("3. saldo setelah kegagalan (tidak ada yang berubah):")
  for (const r of await Rekening.find().sort({ nama: 1 })) {
    console.log("   ", r.nama, ":", r.saldo)
  }

  await mongoose.disconnect()
}

main().catch((err) => {
  console.error("GAGAL:", err.name, "-", err.message)
  process.exit(1)
})
bash
cd /home/z/my-project/tutorial
MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node content/mongodb/mongodb-dengan-nodejs/transactions-mongoose/transfer-saldo.js

Output yang Dihasilkan:

text
1. setelah transfer Budi -> Siti sebesar 25000:
    Budi : 75000
    Siti : 75000
2. transaksi dibatalkan: Saldo Siti tidak cukup (75000 < 999999)
3. saldo setelah kegagalan (tidak ada yang berubah):
    Budi : 75000
    Siti : 75000

Bedah hasilnya: transfer pertama mengurangi saldo Budi dan menambah saldo Siti dalam satu commit — keduanya terlihat pada langkah 1. Transfer kedua melempar Error dari dalam callback karena saldo Siti kurang; transaction() menangkapnya, membatalkan transaksi, lalu melemparkan galat yang sama keluar sehingga catch di pemanggil bisa menanganinya. Langkah 3 adalah bukti rollback: kedua saldo persis seperti sebelum percobaan gagal — tidak ada pengurangan yang tertinggal.

Semua Operasi Harus Ikut Sesi #

Aturan yang paling sering terlewat: transaksi hanya mengikat operasi yang secara eksplisit membawa session. Pada file di atas, tiga tempat melakukannya — .session(session) pada kedua findOne() dan { session } pada kedua save(). Operasi tanpa session tetap berjalan, tetapi di luar transaksi: tulisannya tidak ikut dibatalkan saat rollback, dan bacanya bisa melihat data yang belum di-commit.

Urutan di dalam callback juga bukan kebetulan. Kedua rekening dibaca lebih dulu (dengan session), keputusan diambil berdasarkan nilai yang dibaca, baru keduanya ditulis. Pembacaan di dalam transaksi melihat snapshot data pada saat transaksi dimulai; bila operasi lain mengubah rekening yang sama setelah snapshot dan transaksi kita kemudian menulis document itu, server mendeteksi konflik tulis dan membatalkan transaksi — keputusan dari.saldo < jumlah karena itu tidak bisa diloloskan oleh perubahan di tengah jalan. Pola baca-dengan-sesi, putuskan, tulis-dengan-sesi adalah bentuk paling aman untuk aturan bisnis yang membaca saldo.

Pola Manual: startSession dan withTransaction #

connection.transaction() adalah bentuk paling ringkas, tetapi pola manual memberi kendali penuh atas daur sesi — berguna saat sesi perlu mengalir ke beberapa fungsi atau saat kita ingin log di sekitar commit:

js Pola manual yang setara
// mdtest: skip — padanan eksplisit connection.transaction()
const session = await mongoose.startSession()
try {
  await session.withTransaction(async () => {
    await Rekening.create([{ nama: "Dewi", saldo: 0 }], { session })
    const r = await Rekening.findOne({ nama: "Dewi" }).session(session)
    r.saldo += 100
    await r.save({ session })
  })
} finally {
  await session.endSession()
}

Perhatikan bentuk Model.create([{ ... }], { session }): karena create menerima banyak document, session diberikan sebagai opsi kedua bersama array. Blok finally memastikan endSession() selalu berjalan agar sesi tidak menggantung — pada connection.transaction() langkah ini sudah ditangani pembungkusnya.

Prasyarat dan Perilaku Coba-Ulang #

Transaksi memerlukan replica set — server mongod tunggal tanpa replSet menolaknya dengan galat yang jelas. Lingkungan latihan kita memakai single-node replica set rs0, sehingga transaksi berjalan penuh; di produksi, Atlas dan deployment replica set/sharded sudah memenuhi syarat ini secara bawaan. Biayanya juga patut diingat: satu transaksi menyimpan snapshot di memory selama daur hidupnya, jadi transaksi sebaiknya pendek dan menjangkau sedikit document — memindahkan jutaan baris dalam satu transaksi adalah pola yang salah; untuk itu ada proses batch dengan penanda progres.

Satu perilaku penting dari withTransaction() (dan pembungkusnya): callback bisa dijalankan lebih dari sekali. Bila transaksi mengalami galat sementara — misalnya failover primary — driver mengulang seluruh callback, bukan melanjutkan di tengah. Karena itu callback transaksi sebaiknya idempotent dan tidak membawa efek samping di luar database (kirim email, terbitkan event) — efek samping semacam itu letaknya setelah transaksi sukses, di lapisan service.

Rangkuman #

  • mongoose.connection.transaction(cb) meng-commit saat callback selesai dan membatalkan semuanya saat callback melempar galat; sesi dikelola otomatis.
  • Setiap operasi di dalam transaksi wajib membawa session: .session(session) untuk query, { session } untuk tulis; operasi tanpa session berada di luar transaksi.
  • Pola manual startSession() + withTransaction() + endSession() memberi kendali penuh; Model.create([...], { session }) menerima sesi sebagai opsi kedua.
  • Transaksi butuh replica set (rs0 lokal memenuhi); callback bisa diulang driver, jadi buat idempotent dan letakkan efek samping non-database di luar transaksi.