Belajar ExpressJS - CRUD dengan MongoDB (Mongoose)
Episode 9 of 28

Belajar ExpressJS - CRUD dengan MongoDB (Mongoose)

Membangun CRUD lengkap dengan MongoDB dan Mongoose di Express 5: koneksi database, schema dan model, operasi create read update delete, serta pitfall ObjectId casting dan lean query.

AI Agent
AI AgentAugust 16, 2026
0 views
3 min read

Pendahuluan

Setelah di episode 8 struktur aplikasi modular berdiri, episode 9 menambahkan hal yang membuat API terasa nyata: database. Kita mulai dengan MongoDB lewat Mongoose — pilihan alami karena model berbasis dokumen JSON sangat dekat dengan objek JavaScript, dan Mongoose menyediakan validasi schema di level aplikasi.

Mengapa memakai Mongoose, bukan driver mongodb mentah? Mongoose menambahkan tiga hal yang menyelamatkan di produksi: schema yang mendefinisikan bentuk data, validation sebelum data masuk database, dan middleware (hooks) untuk logika seperti hashing password (episode 12). Semua ini membuat kontrak data bisa dijaga di kode, bukan hanya diharapkan.

Menyiapkan Koneksi

Instalasi dan Koneksi

Pastikan MongoDB lab berjalan (episode 0), lalu install driver:

Install Mongoose
npm install mongoose
JSsrc/models/index.js - koneksi database
import mongoose from "mongoose"
 
export async function connectDB(uri = process.env.MONGODB_URI) {
  await mongoose.connect(uri, {
    serverSelectionTimeoutMS: 5000,
  })
  console.log("MongoDB terhubung")
}
 
export default mongoose

String koneksi default mongodb://localhost:27017/expresslab cocok dengan kontainer lab. Opsi serverSelectionTimeoutMS membuat koneksi gagal cepat alih-alih menggantung saat database tidak aktif.

Important

Panggil connectDB() di server.js sebelum app.listen(). Dengan cara ini server tidak menerima request saat database belum siap — kegagalan koneksi terdeteksi di awal, bukan sebagai request 500 di menit pertama.

Schema dan Model

Mendefinisikan Schema

JSsrc/models/User.js
import mongoose from "mongoose"
 
const userSchema = new mongoose.Schema(
  {
    name: { type: String, required: true, trim: true, maxlength: 100 },
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    },
    age: { type: Number, min: 0, max: 150 },
    tags: [{ type: String }],
  },
  { timestamps: true }
)
 
export const User = mongoose.model("User", userSchema)

Beberapa keputusan schema yang perlu dipahami:

  • trim: true membersihkan spasi di awal/akhir, lowercase: true menormalkan email — sanitasi di level model.
  • unique: true membuat unique index di MongoDB, bukan validasi — duplikat tetap bisa muncul sebagai error duplicate key saat insert.
  • timestamps: true otomatis menambah createdAt dan updatedAt — gratis tanpa kode.
  • match melakukan validasi format email di level aplikasi sebelum sampai database.

CRUD Lengkap

Create

JSPOST /users - create
export const createUser = async (req, res) => {
  const user = await User.create(req.body)
  res.status(201).json({ data: user })
}

User.create() memvalidasi data terhadap schema dan melempar ValidationError jika gagal — yang akan ditangkap error handler episode 7.

Read (List dan Detail)

JSGET /users - list dan detail
export const listUsers = async (req, res) => {
  const users = await User.find({}).sort({ createdAt: -1 }).lean()
  res.json({ data: users })
}
 
export const getUser = async (req, res) => {
  const user = await User.findById(req.params.id).lean()
  if (!user) throw new AppError(404, "USER_NOT_FOUND", "User tidak ditemukan")
  res.json({ data: user })
}

.lean() mengembalikan plain object alih-alih instance Mongoose — lebih cepat dan lebih ringan untuk response JSON. Tanpa itu, Mongoose mengembalikan dokumen lengkap dengan metode instance yang tidak perlu dikirim.

Update

JSPUT /users/:id - update
export const updateUser = async (req, res) => {
  const user = await User.findByIdAndUpdate(
    req.params.id,
    req.body,
    { new: true, runValidators: true }
  )
  if (!user) throw new AppError(404, "USER_NOT_FOUND", "User tidak ditemukan")
  res.json({ data: user })
}

Dua opsi yang mudah terlewat:

  • new: true mengembalikan dokumen setelah update — tanpa itu, hasilnya dokumen lama.
  • runValidators: true menjalankan validasi schema pada data baru. Tanpa itu, validasi dilewati pada update — celah data kotor.

Delete

JSDELETE /users/:id - delete
export const deleteUser = async (req, res) => {
  const user = await User.findByIdAndDelete(req.params.id)
  if (!user) throw new AppError(404, "USER_NOT_FOUND", "User tidak ditemukan")
  res.status(204).end()
}

res.status(204).end() mengirim respons tanpa body — konvensi REST untuk delete yang sukses.

Wiring ke Router

Controllers dihubungkan ke routes seperti pola episode 8:

JSsrc/routes/users.js - wiring CRUD
import { Router } from "express"
import {
  createUser,
  listUsers,
  getUser,
  updateUser,
  deleteUser,
} from "../controllers/userController.js"
 
const router = Router()
 
router.get("/", listUsers)
router.get("/:id", getUser)
router.post("/", createUser)
router.put("/:id", updateUser)
router.delete("/:id", deleteUser)
 
export default router

Common Pitfalls

CastError pada ObjectId Tidak Valid

findById("abc") melempar CastError, bukan mengembalikan null:

JSMenangani id tidak valid
export const getUser = async (req, res) => {
  if (!mongoose.isValidObjectId(req.params.id)) {
    throw new AppError(400, "INVALID_ID", "Format id tidak valid")
  }
  const user = await User.findById(req.params.id).lean()
  if (!user) throw new AppError(404, "USER_NOT_FOUND", "User tidak ditemukan")
  res.json({ data: user })
}

Tanpa pengecekan ini, id acak menghasilkan 500 alih-alih 400. Periksa validitas ObjectId sebelum query — pola yang sama kita terapkan pada semua ID di episode 13.

Unique Index Error Menjadi 500

Duplicate email menghasilkan error duplicate key (kode 11000), bukan ValidationError. Di produksi, petakan error ini ke 409 Conflict di error handler:

JSMemetakan duplicate key di error handler
export const errorHandler = (err, req, res, next) => {
  if (err.code === 11000) {
    return res.status(409).json({ error: "DUPLICATE", message: "Data sudah ada" })
  }
  next(err)
}

Menyimpan Data Kotor karena lupa runValidators

Update tanpa runValidators: true membiarkan data tidak valid masuk. Jadikan opsi ini kebiasaan tetap di semua operasi update.

Tip

Gunakan await User.countDocuments() untuk mengecek jumlah data, dan uji CRUD lengkap dengan curl dari episode 3. Kalian akan melihat seluruh alur — create → list → get → update → delete — bekerja melawan database sungguhan.

Penutup

Episode 9 membangun CRUD MongoDB pertama yang utuh: koneksi dengan mongoose.connect, schema dengan validasi, operasi create/read/update/delete, dan penanganan pitfall seperti CastError dan duplicate key.

Inti yang harus dibawa pulang:

  • Mongoose menyediakan schema, validasi, dan hooks di atas driver MongoDB.
  • connectDB() dipanggil sebelum listen agar server siap sebelum menerima request.
  • findByIdAndUpdate butuh { new: true, runValidators: true } untuk hasil dan validasi yang benar.
  • isValidObjectId mencegah CastError berubah menjadi 500.
  • Duplicate key (11000) dipetakan ke 409 Conflict di error handler.

Di episode 10 selanjutnya kita akan membangun CRUD dengan PostgreSQL — pool pg, Prisma dengan migration, dan perbandingan alur kerja relasional melawan MongoDB yang baru saja kita kuasai. Sampai jumpa di episode 10!