Ada logika yang harus berjalan pada setiap penulisan data: nama dipangkas spasi, slug diturunkan dari judul, password di-hash, waktu edit dicatat. Jika logika itu ditulis di setiap handler route, cepat atau lambat satu tempat terlewat dan datanya melenceng. Middleware Mongoose memindahkan logika tersebut ke dalam schema: fungsi terdaftar untuk event tertentu (save, findOneAndUpdate, dan lain-lain) dan berjalan otomatis di mana pun operasi itu dipanggil. Bab ini mempraktikkan normalisasi dan slug otomatis, lalu menghadapi aturan pentingnya: jenis middleware berbeda menerima this yang berbeda.

Hook Siklus Hidup dan Bentuk Penulisannya #

Middleware dideklarasikan dengan schema.pre(namaEvent, fn) (sebelum) dan schema.post(namaEvent, fn) (sesudah). Event yang paling sering dipakai:

EventKapan berjalanthis di dalam fungsi
savesebelum/sesudah save() dan create()document
validatesebelum/sesudah validasidocument
findOneAndUpdatesebelum query update dijalankanobjek Query
deleteOne, findOneAndDeletesebelum query hapus dijalankanobjek Query
insertManysebelum penyisipan massalarray document

Di Mongoose 9, hook ditulis sebagai fungsi sinkron atau async — tanpa parameter next:

js Bentuk hook Mongoose 9 — tanpa callback next
// mdtest: skip — ilustrasi bentuk; implementasi lengkap ada di file di bawah
skema.pre("save", async function () {
  this.slug = buatSlug(this.judul) // cukup selesai, atau await operasi async
})

Normalisasi, Slug, dan Stempel Waktu Otomatis #

File berikut memasang tiga hook: pre("save") merapikan input dan menurunkan slug, post("save") mencatat penyimpanan, dan pre("findOneAndUpdate") menjaga aturan slug tetap berlaku pada jalur update:

js
// middleware-artikel.js — pre/post save dan query middleware findOneAndUpdate
const mongoose = require("mongoose")

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

const buatSlug = (teks) =>
  teks.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")

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

  const artikelSchema = new mongoose.Schema({
    judul: { type: String, required: true },
    slug: String,
    penulisEmail: String,
    ringkas: String,
    diedit: Date, // diisi middleware, bukan opsi timestamps
  })

  // document middleware: this = document yang akan disimpan
  artikelSchema.pre("save", async function () {
    console.log("[pre save] this adalah document:", this.constructor.name)
    this.judul = this.judul.trim()
    if (this.penulisEmail) {
      this.penulisEmail = this.penulisEmail.trim().toLowerCase()
    }
    this.slug = buatSlug(this.judul)
    this.diedit = new Date()
  })

  artikelSchema.post("save", (doc) => {
    console.log("[post save] tersimpan:", doc.slug)
  })

  // query middleware: this = Query, BUKAN document
  artikelSchema.pre("findOneAndUpdate", async function () {
    console.log("[pre findOneAndUpdate] this instanceof Query:", this instanceof mongoose.Query)
    const pembaruan = this.getUpdate()
    const judulBaru = pembaruan.$set?.judul
    if (judulBaru) {
      this.setUpdate({
        ...pembaruan,
        $set: { ...pembaruan.$set, slug: buatSlug(judulBaru), diedit: new Date() },
      })
    }
  })

  const Artikel = mongoose.model("Artikel", artikelSchema)

  const a = await Artikel.create({
    judul: "  Belajar MongoDB  ",
    penulisEmail: "  [email protected] ",
    ringkas: "Pengenalan document database",
  })
  console.log("1. judul:", JSON.stringify(a.judul), "| slug:", a.slug)
  console.log("   email:", a.penulisEmail, "| diedit bertipe Date:", a.diedit instanceof Date)

  const ubah = await Artikel.findOneAndUpdate(
    { slug: "belajar-mongodb" },
    { $set: { judul: "MongoDB untuk Aplikasi Nyata" } },
    { returnDocument: "after" }
  )
  console.log("2. setelah update — judul:", ubah.judul, "| slug:", ubah.slug)

  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/middleware-mongoose/middleware-artikel.js

Output yang Dihasilkan:

text
[pre save] this adalah document: model
[post save] tersimpan: belajar-mongodb
1. judul: "Belajar MongoDB" | slug: belajar-mongodb
   email: [email protected] | diedit bertipe Date: true
[pre findOneAndUpdate] this instanceof Query: true
2. setelah update — judul: MongoDB untuk Aplikasi Nyata | slug: mongodb-untuk-aplikasi-nyata

Mari kita baca hasilnya. Judul " Belajar MongoDB " tersimpan bersih sebagai "Belajar MongoDB" — spasi di awal dan akhir dipangkas oleh pre("save") sebelum document menyentuh database. Slug belajar-mongodb dan email huruf kecil berasal dari hook yang sama, dan field diedit terisi Date tanpa pemanggil menulisnya. Baris [post save] menunjukkan hook setelah penyimpanan menerima doc yang baru tersimpan — tempat yang tepat untuk mencatat audit atau mengirim event. Pada findOneAndUpdate, hook mengambil isi pembaruan lewat this.getUpdate(), menambahkan slug dan diedit, lalu memasangnya kembali dengan this.setUpdate(...) — dan output membuktikan hasilnya: judul baru membawa slug baru. Semua itu terjadi tanpa satu baris pun di kode pemanggil, yang hanya mengirim judul mentah dan mengharapkan sisanya diurus schema.

Query Middleware: this Bukan Document #

Perhatikan dua baris log berbeda pada output: pre("save") melaporkan this sebagai model (class document), sedangkan pre("findOneAndUpdate") melaporkan this instanceof Query: true. Inilah pembagian fundamental di Mongoose: middleware document menerima document utuh yang bisa dimodifikasi langsung (this.judul = ...), sedangkan middleware query menerima objek Query — tidak ada document yang bisa diubah karena data lama bahkan mungkin belum dibaca. Hook query bekerja dengan filter (this.getFilter()) dan pembaruan (this.getUpdate()/this.setUpdate()).

Konsekuensinya praktis: save() dan create() menjalankan hook document, tetapi updateOne(), findByIdAndUpdate() (padanan findOneAndUpdate), dan deleteOne() menjalankan hook query — keduanya dunia yang terpisah. Aturan yang perlu berlaku di kedua jalur (seperti slug kita) harus didaftarkan pada kedua event. Pemisahan ini juga menjelaskan mengapa diedit pada contoh tidak memakai { timestamps: true }: opsi tersebut memang sudah mengurus createdAt/updatedAt di kedua jalur, jadi menulis stempel waktu manual seperti di atas hanya tepat bila kita butuh nama field, logika, atau peristiwa yang berbeda dari yang ditawarkan opsi bawaan.

Use Case dan Batasnya #

Empat pola yang paling umum di aplikasi nyata:

  • Normalisasi input — trim, huruf kecil, format nomor telepon; murah dan selalu aman di pre("save").
  • Penurunan nilai — slug dari judul, total dari rincian item; hitung ulang di setiap jalur tulis agar tidak basi.
  • Hash kredensial — pre("save") meng-hash password dengan node:crypto atau bcrypt; jaga agar hanya berjalan saat field berubah (this.isModified("password")) agar penyimpanan ulang tanpa ganti password tidak meng-hash ulang.
  • Stempel waktu dan audit — diedit pada contoh kita adalah versi manual dari { timestamps: true }; post hook cocok untuk log audit dan notifikasi karena document sudah berada di database.

Rangkuman #

  • Middleware dideklarasikan per event di schema; di Mongoose 9 penulisannya fungsi sinkron atau async tanpa callback next.
  • pre/post("save") menerima document sebagai this; pre("findOneAndUpdate") dan sesama query hook menerima objek Query — ubah pembaruan lewat getUpdate()/setUpdate().
  • Normalisasi, penurunan nilai, hash kredensial, dan stempel waktu adalah use case utama; daftarkan aturan yang sama pada event document dan event query bila keduanya dipakai.
  • post hook adalah tempat log audit dan efek samping setelah data benar-benar tersimpan.