MongoDB #

MongoDB adalah database NoSQL berbasis dokumen yang menyimpan data dalam format BSON — struktur mirip JSON yang fleksibel dan tidak memerlukan skema yang kaku. Berbeda dengan database relasional yang mengharuskan kamu mendefinisikan tabel dan kolom terlebih dahulu, MongoDB membiarkan setiap dokumen dalam satu koleksi memiliki struktur yang berbeda. Fleksibilitas ini menjadikannya pilihan yang tepat untuk data yang sifatnya dinamis, hierarkis, atau sering berubah strukturnya. Di TypeScript, kombinasi MongoDB dengan sistem tipe yang kuat menghasilkan kode yang aman sekaligus ekspresif — kamu mendapatkan fleksibilitas NoSQL tanpa kehilangan keamanan tipe saat compile time.

Instalasi #

Driver resmi MongoDB untuk Node.js sudah mendukung TypeScript secara native dan tidak memerlukan paket @types tambahan.

npm install mongodb

Jika kamu menggunakan Mongoose (ODM yang populer untuk MongoDB), instalasinya berbeda:

npm install mongoose
npm install --save-dev @types/mongoose

Artikel ini berfokus pada driver resmi MongoDB (mongodb), bukan Mongoose. Driver resmi memberikan kontrol lebih langsung ke database dan lebih ringan untuk use case yang tidak memerlukan ODM penuh.


Koneksi ke Database #

Sebelum melakukan operasi apapun, kamu perlu membuat koneksi ke MongoDB melalui MongoClient. Selalu kelola koneksi dengan hati-hati — membuat koneksi baru untuk setiap operasi adalah anti-pattern yang mahal.

import { MongoClient, Db } from "mongodb";

// ANTI-PATTERN: membuat koneksi baru di setiap fungsi
async function getUser(id: string) {
  const client = new MongoClient("mongodb://localhost:27017"); // ✗ koneksi baru setiap panggilan
  await client.connect();
  const db = client.db("myapp");
  // ...
}

// BENAR: singleton pattern untuk koneksi
class Database {
  private static client: MongoClient;
  private static db: Db;

  static async connect(uri: string, dbName: string): Promise<Db> {
    if (!Database.client) {
      Database.client = new MongoClient(uri, {
        maxPoolSize: 10,        // maksimal 10 koneksi di pool
        minPoolSize: 2,         // minimal 2 koneksi selalu aktif
        serverSelectionTimeoutMS: 5000,
        connectTimeoutMS: 10000,
      });
      await Database.client.connect();
      Database.db = Database.client.db(dbName);
    }
    return Database.db;
  }

  static async disconnect(): Promise<void> {
    if (Database.client) {
      await Database.client.close();
    }
  }
}

// penggunaan
const db = await Database.connect("mongodb://localhost:27017", "myapp");

Connection string untuk berbagai environment:

# lokal tanpa autentikasi
mongodb://localhost:27017

# lokal dengan autentikasi
mongodb://username:password@localhost:27017/dbname

# MongoDB Atlas (cloud)
mongodb+srv://username:[email protected]/dbname?retryWrites=true&w=majority

# replica set
mongodb://host1:27017,host2:27017,host3:27017/dbname?replicaSet=myReplicaSet

Mendefinisikan Type untuk Dokumen #

Salah satu keunggulan menggunakan TypeScript dengan MongoDB adalah kemampuan mendefinisikan tipe untuk setiap koleksi. Driver MongoDB menyediakan generics yang memungkinkan type safety di seluruh operasi.

import { ObjectId, Document } from "mongodb";

// interface untuk dokumen yang disimpan di database
interface Produk {
  _id?: ObjectId;
  nama: string;
  harga: number;
  kategori: string;
  stok: number;
  tags: string[];
  createdAt: Date;
  updatedAt: Date;
}

// interface untuk dokumen User yang memiliki relasi
interface User {
  _id?: ObjectId;
  nama: string;
  email: string;
  alamat: {
    jalan: string;
    kota: string;
    provinsi: string;
    kodePos: string;
  };
  createdAt: Date;
}

// mendapatkan collection dengan tipe yang terdefinisi
const produkCollection = db.collection<Produk>("produk");
const userCollection = db.collection<User>("users");

Dengan pendekatan ini, TypeScript akan memberikan error saat kamu mencoba menyisipkan dokumen yang tidak sesuai dengan interface, atau saat mengakses field yang tidak ada.


Operasi CRUD #

Insert — Menyimpan Dokumen #

insertOne untuk satu dokumen, insertMany untuk banyak dokumen sekaligus.

import { ObjectId } from "mongodb";

const produkCollection = db.collection<Produk>("produk");

// insert satu dokumen
async function tambahProduk(data: Omit<Produk, "_id">): Promise<ObjectId> {
  const hasil = await produkCollection.insertOne({
    ...data,
    createdAt: new Date(),
    updatedAt: new Date(),
  });
  return hasil.insertedId;
}

// insert banyak dokumen sekaligus — lebih efisien dari loop insertOne
async function tambahBanyakProduk(dataProduk: Omit<Produk, "_id">[]): Promise<number> {
  const dokumenSiapInsert = dataProduk.map((p) => ({
    ...p,
    createdAt: new Date(),
    updatedAt: new Date(),
  }));

  const hasil = await produkCollection.insertMany(dokumenSiapInsert, {
    ordered: false, // lanjutkan meski ada yang gagal
  });

  return hasil.insertedCount;
}

// contoh penggunaan
const id = await tambahProduk({
  nama: "Laptop Gaming X1",
  harga: 15000000,
  kategori: "elektronik",
  stok: 50,
  tags: ["laptop", "gaming", "performa-tinggi"],
  createdAt: new Date(),
  updatedAt: new Date(),
});

Read — Membaca Dokumen #

Query di MongoDB menggunakan filter object. Driver menyediakan tipe Filter<T> untuk memastikan field yang kamu query sesuai dengan interface dokumen.

import { Filter, ObjectId } from "mongodb";

// cari satu dokumen berdasarkan ID
async function cariProdukById(id: string): Promise<Produk | null> {
  return produkCollection.findOne({ _id: new ObjectId(id) });
}

// cari berdasarkan kriteria dengan proyeksi
async function cariProdukByKategori(
  kategori: string,
  hargaMaks?: number
): Promise<Produk[]> {
  const filter: Filter<Produk> = { kategori };

  if (hargaMaks !== undefined) {
    filter.harga = { $lte: hargaMaks };
  }

  return produkCollection
    .find(filter)
    .sort({ harga: 1 })   // urutkan dari harga terendah
    .limit(20)
    .toArray();
}

// pagination dengan skip dan limit
async function daftarProduk(halaman: number, perHalaman: number = 10) {
  const skip = (halaman - 1) * perHalaman;

  const [produk, total] = await Promise.all([
    produkCollection
      .find({})
      .skip(skip)
      .limit(perHalaman)
      .toArray(),
    produkCollection.countDocuments({}),
  ]);

  return {
    data: produk,
    total,
    halaman,
    totalHalaman: Math.ceil(total / perHalaman),
  };
}

Operator query yang sering digunakan:

// operator perbandingan
{ harga: { $gt: 100000 } }          // lebih besar dari
{ harga: { $gte: 100000 } }         // lebih besar atau sama dengan
{ harga: { $lt: 500000 } }          // lebih kecil dari
{ harga: { $lte: 500000 } }         // lebih kecil atau sama dengan
{ harga: { $ne: 0 } }               // tidak sama dengan
{ harga: { $in: [10000, 20000] } }  // ada di dalam array

// operator logika
{ $and: [{ kategori: "elektronik" }, { stok: { $gt: 0 } }] }
{ $or: [{ kategori: "laptop" }, { kategori: "tablet" }] }
{ $nor: [{ stok: 0 }, { harga: 0 }] }

// operator array
{ tags: { $in: ["gaming"] } }        // array mengandung salah satu
{ tags: { $all: ["gaming", "laptop"] } }  // array mengandung semua
{ tags: { $size: 3 } }              // array punya tepat 3 elemen

// operator teks (butuh text index)
{ $text: { $search: "laptop gaming" } }

Update — Memperbarui Dokumen #

MongoDB menyediakan berbagai operator update. Hindari mengganti seluruh dokumen kecuali memang diperlukan.

import { UpdateFilter } from "mongodb";

// ANTI-PATTERN: mengganti seluruh dokumen — menghapus field yang tidak disertakan
async function updateProdukSalah(id: string, data: Partial<Produk>) {
  await produkCollection.replaceOne(
    { _id: new ObjectId(id) },
    data as Produk  // ✗ menghapus field seperti createdAt, tags, dll
  );
}

// BENAR: gunakan $set untuk update parsial
async function updateProduk(
  id: string,
  data: Partial<Omit<Produk, "_id" | "createdAt">>
): Promise<boolean> {
  const hasil = await produkCollection.updateOne(
    { _id: new ObjectId(id) },
    {
      $set: {
        ...data,
        updatedAt: new Date(),
      },
    }
  );
  return hasil.matchedCount > 0;
}

// update dengan operator atomik — aman untuk operasi konkuren
async function kurangiStok(idProduk: string, jumlah: number): Promise<boolean> {
  const hasil = await produkCollection.updateOne(
    {
      _id: new ObjectId(idProduk),
      stok: { $gte: jumlah },  // pastikan stok cukup sebelum dikurangi
    },
    {
      $inc: { stok: -jumlah },        // kurangi stok
      $set: { updatedAt: new Date() },
    }
  );
  return hasil.modifiedCount > 0;
}

// upsert — insert jika tidak ada, update jika ada
async function upsertProduk(
  nama: string,
  data: Partial<Produk>
): Promise<void> {
  await produkCollection.updateOne(
    { nama },
    {
      $set: { ...data, updatedAt: new Date() },
      $setOnInsert: { createdAt: new Date() }, // hanya diset saat insert
    },
    { upsert: true }
  );
}

Operator update yang penting:

$set: { field: nilai }           // set nilai field
$unset: { field: "" }            // hapus field dari dokumen
$inc: { stok: -1 }               // tambah/kurangi nilai numerik
$push: { tags: "baru" }          // tambah elemen ke array
$pull: { tags: "lama" }          // hapus elemen dari array
$addToSet: { tags: "unik" }      // tambah ke array hanya jika belum ada
$rename: { namaLama: "namaBaru" } // ganti nama field

Delete — Menghapus Dokumen #

// hapus satu dokumen
async function hapusProduk(id: string): Promise<boolean> {
  const hasil = await produkCollection.deleteOne({
    _id: new ObjectId(id),
  });
  return hasil.deletedCount > 0;
}

// hapus banyak dokumen berdasarkan kriteria
async function hapusProdukTidakAktif(): Promise<number> {
  const hasil = await produkCollection.deleteMany({
    stok: 0,
    updatedAt: { $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, // tidak diupdate 30 hari
  });
  return hasil.deletedCount;
}

// soft delete — praktik yang lebih aman untuk production
async function softDeleteProduk(id: string): Promise<boolean> {
  const hasil = await produkCollection.updateOne(
    { _id: new ObjectId(id) },
    {
      $set: {
        deletedAt: new Date(),
        updatedAt: new Date(),
      },
    }
  );
  return hasil.modifiedCount > 0;
}
Operasi deleteMany tanpa filter ({}) akan menghapus seluruh dokumen di koleksi. Selalu verifikasi filter sebelum menjalankan bulk delete di production. Pertimbangkan soft delete (menambah field deletedAt) daripada hard delete untuk data yang mungkin perlu dipulihkan.

Aggregation Pipeline #

Aggregation pipeline adalah mekanisme paling powerful di MongoDB untuk memproses dan mentransformasi data. Pipeline terdiri dari beberapa stage yang memproses dokumen secara berurutan — output satu stage menjadi input stage berikutnya.

flowchart LR
    A[(Koleksi)] --> B["$match\n(filter dokumen)"]
    B --> C["$group\n(kelompokkan & hitung)"]
    C --> D["$sort\n(urutkan hasil)"]
    D --> E["$project\n(pilih field)"]
    E --> F[Hasil Akhir]

Stage-stage Aggregation yang Sering Digunakan #

interface HasilPenjualan {
  _id: string;         // kategori
  totalPendapatan: number;
  jumlahTransaksi: number;
  rataRataHarga: number;
}

async function laporanPenjualanPerKategori(
  tanggalMulai: Date,
  tanggalAkhir: Date
): Promise<HasilPenjualan[]> {
  return produkCollection
    .aggregate<HasilPenjualan>([
      // Stage 1: filter dokumen
      {
        $match: {
          createdAt: {
            $gte: tanggalMulai,
            $lte: tanggalAkhir,
          },
          stok: { $gt: 0 },
        },
      },

      // Stage 2: kelompokkan dan hitung
      {
        $group: {
          _id: "$kategori",
          totalPendapatan: { $sum: { $multiply: ["$harga", "$stok"] } },
          jumlahTransaksi: { $count: {} },
          rataRataHarga: { $avg: "$harga" },
        },
      },

      // Stage 3: urutkan berdasarkan pendapatan
      {
        $sort: { totalPendapatan: -1 },
      },

      // Stage 4: format output
      {
        $project: {
          _id: 1,
          totalPendapatan: { $round: ["$totalPendapatan", 0] },
          jumlahTransaksi: 1,
          rataRataHarga: { $round: ["$rataRataHarga", 0] },
        },
      },
    ])
    .toArray();
}

Lookup — Join Antar Koleksi #

MongoDB mendukung operasi join melalui stage $lookup. Gunakan ini untuk menggabungkan data dari dua koleksi berbeda.

interface OrderDenganUser {
  _id: ObjectId;
  produkId: ObjectId;
  jumlah: number;
  user: User[];  // hasil lookup
}

async function orderDenganDetailUser(): Promise<OrderDenganUser[]> {
  return db
    .collection("orders")
    .aggregate<OrderDenganUser>([
      {
        $match: {
          status: "selesai",
        },
      },
      {
        $lookup: {
          from: "users",          // koleksi yang di-join
          localField: "userId",   // field di koleksi orders
          foreignField: "_id",    // field di koleksi users
          as: "user",             // nama field output
        },
      },
      {
        $unwind: {
          path: "$user",
          preserveNullAndEmptyArrays: true, // tetap tampilkan order meski user tidak ditemukan
        },
      },
    ])
    .toArray();
}

Contoh Pipeline Kompleks #

Berikut contoh pipeline yang menghitung statistik produk per kategori sekaligus menambahkan informasi persentase:

async function statistikProduk() {
  return produkCollection
    .aggregate([
      // hitung total produk terlebih dahulu
      {
        $facet: {
          // jalur 1: statistik per kategori
          perKategori: [
            {
              $group: {
                _id: "$kategori",
                jumlahProduk: { $count: {} },
                totalStok: { $sum: "$stok" },
                hargaTerendah: { $min: "$harga" },
                hargaTertinggi: { $max: "$harga" },
              },
            },
            { $sort: { jumlahProduk: -1 } },
          ],

          // jalur 2: total keseluruhan untuk menghitung persentase
          total: [
            {
              $group: {
                _id: null,
                totalProduk: { $count: {} },
              },
            },
          ],
        },
      },

      // gabungkan kedua jalur
      {
        $project: {
          perKategori: {
            $map: {
              input: "$perKategori",
              as: "kategori",
              in: {
                nama: "$$kategori._id",
                jumlahProduk: "$$kategori.jumlahProduk",
                totalStok: "$$kategori.totalStok",
                hargaTerendah: "$$kategori.hargaTerendah",
                hargaTertinggi: "$$kategori.hargaTertinggi",
                persentase: {
                  $round: [
                    {
                      $multiply: [
                        {
                          $divide: [
                            "$$kategori.jumlahProduk",
                            { $arrayElemAt: ["$total.totalProduk", 0] },
                          ],
                        },
                        100,
                      ],
                    },
                    1,
                  ],
                },
              },
            },
          },
        },
      },
    ])
    .toArray();
}

Indexing #

Index adalah faktor terbesar yang menentukan performa query MongoDB. Tanpa index, MongoDB harus membaca seluruh koleksi untuk setiap query — disebut collection scan. Dengan index yang tepat, MongoDB bisa langsung melompat ke dokumen yang relevan.

flowchart TD
    A["Query diterima"] --> B{"Ada index<br/>yang cocok?"}

    B -- Ya --> C["Index Scan<br/>O(log n)"]
    B -- Tidak --> D["Collection Scan<br/>O(n)"]

    C --> E["Dokumen ditemukan"]
    D --> E

    E --> F["Hasil dikembalikan"]

    style C fill:#16a34a,color:#fff
    style D fill:#dc2626,color:#fff

Membuat Index #

async function setupIndex(): Promise<void> {
  // single field index — paling umum
  await produkCollection.createIndex({ kategori: 1 });

  // compound index — untuk query yang sering melibatkan dua field atau lebih
  // urutan field sangat penting: sesuaikan dengan urutan field di query kamu
  await produkCollection.createIndex(
    { kategori: 1, harga: 1 },
    { name: "idx_kategori_harga" }
  );

  // unique index — memastikan tidak ada nilai duplikat
  await userCollection.createIndex(
    { email: 1 },
    { unique: true, name: "idx_email_unique" }
  );

  // text index — untuk pencarian teks
  await produkCollection.createIndex(
    { nama: "text", deskripsi: "text" },
    { name: "idx_text_search" }
  );

  // partial index — hanya mengindex dokumen yang memenuhi kondisi
  // jauh lebih kecil dan efisien dibanding full index
  await produkCollection.createIndex(
    { stok: 1 },
    {
      partialFilterExpression: { stok: { $gt: 0 } },
      name: "idx_stok_tersedia",
    }
  );

  // TTL index — dokumen otomatis dihapus setelah waktu tertentu
  await db.collection("sessions").createIndex(
    { createdAt: 1 },
    {
      expireAfterSeconds: 3600, // hapus setelah 1 jam
      name: "idx_ttl_session",
    }
  );
}

Menganalisis Performa Query #

Gunakan explain() untuk melihat apakah query menggunakan index atau tidak:

async function analyzeQuery() {
  const result = await produkCollection
    .find({ kategori: "elektronik", harga: { $lt: 5000000 } })
    .explain("executionStats");

  console.log("Stage:", result.queryPlanner?.winningPlan?.stage);
  // "IXSCAN" = menggunakan index ✓
  // "COLLSCAN" = collection scan, perlu index ✗

  console.log(
    "Dokumen diperiksa:",
    result.executionStats?.totalDocsExamined
  );
  console.log(
    "Dokumen dikembalikan:",
    result.executionStats?.totalDocsReturned
  );
  // Rasio ideal: totalDocsExamined mendekati totalDocsReturned
}

Transaksi #

MongoDB mendukung multi-document ACID transaction sejak versi 4.0 (hanya pada replica set dan sharded cluster). Gunakan transaksi untuk operasi yang harus berhasil atau gagal secara atomik.

async function transferStok(
  idProdukAsal: string,
  idProdukTujuan: string,
  jumlah: number
): Promise<void> {
  const session = Database.client.startSession();

  try {
    await session.withTransaction(async () => {
      // kurangi stok produk asal
      const hasilKurang = await produkCollection.updateOne(
        {
          _id: new ObjectId(idProdukAsal),
          stok: { $gte: jumlah },
        },
        {
          $inc: { stok: -jumlah },
          $set: { updatedAt: new Date() },
        },
        { session }
      );

      if (hasilKurang.modifiedCount === 0) {
        throw new Error("Stok tidak mencukupi atau produk tidak ditemukan");
      }

      // tambah stok produk tujuan
      await produkCollection.updateOne(
        { _id: new ObjectId(idProdukTujuan) },
        {
          $inc: { stok: jumlah },
          $set: { updatedAt: new Date() },
        },
        { session }
      );
    });
  } finally {
    await session.endSession();
  }
}
Transaksi MongoDB memerlukan replica set atau sharded cluster — tidak bisa digunakan pada standalone MongoDB. Untuk development lokal, kamu bisa menjalankan MongoDB sebagai single-node replica set dengan perintah mongod --replSet rs0.

Penanganan Error #

MongoDB melempar error spesifik yang bisa kamu tangkap dan tangani secara tepat.

import { MongoError, MongoServerError, WriteConcernError } from "mongodb";

async function simpanUserAman(data: Omit<User, "_id">): Promise<ObjectId | null> {
  try {
    const hasil = await userCollection.insertOne({
      ...data,
      createdAt: new Date(),
    });
    return hasil.insertedId;
  } catch (error) {
    if (error instanceof MongoServerError) {
      // error kode 11000 = duplicate key (unique constraint violation)
      if (error.code === 11000) {
        const fieldDuplikat = Object.keys(error.keyValue || {}).join(", ");
        throw new Error(`Data sudah ada: ${fieldDuplikat} telah terdaftar`);
      }
    }

    if (error instanceof MongoError) {
      // error koneksi atau timeout
      if (error.message.includes("connection")) {
        throw new Error("Gagal terhubung ke database, coba lagi sebentar");
      }
    }

    throw error; // lempar ulang error yang tidak dikenal
  }
}

// wrapper retry untuk operasi yang mungkin gagal sementara
async function withRetry<T>(
  operasi: () => Promise<T>,
  maxRetry: number = 3,
  delayMs: number = 1000
): Promise<T> {
  let percobaan = 0;

  while (percobaan < maxRetry) {
    try {
      return await operasi();
    } catch (error) {
      percobaan++;

      const bisaRetry =
        error instanceof MongoError &&
        (error.message.includes("connection") ||
          error.message.includes("timeout"));

      if (!bisaRetry || percobaan >= maxRetry) {
        throw error;
      }

      await new Promise((resolve) => setTimeout(resolve, delayMs * percobaan));
    }
  }

  throw new Error("Operasi gagal setelah semua percobaan");
}

Kapan Menggunakan MongoDB vs Database Relasional #

Memilih database adalah keputusan arsitektur yang penting. MongoDB bukan selalu pilihan terbaik.

Pilih MongoDB jika:
  ✓ Struktur data tidak seragam atau sering berubah
  ✓ Data bersifat hierarkis dan sering diakses bersama (produk + varian + gambar)
  ✓ Perlu horizontal scaling yang mudah (sharding)
  ✓ Kecepatan read/write sangat kritis dan relasi data minimal
  ✓ Menyimpan data seperti log, event, atau konten yang schema-less

Pilih database relasional (PostgreSQL, MySQL) jika:
  ✗ Data sangat relasional dengan banyak join antar tabel
  ✗ Memerlukan ACID transaction yang kompleks lintas banyak koleksi
  ✗ Data keuangan atau transaksi yang menuntut konsistensi tinggi
  ✗ Tim sudah familiar dan ekosistem SQL lebih cocok
  ✗ Butuh query ad-hoc yang fleksibel dengan join kompleks

Ringkasan #

  • Driver resmi mongodb sudah mendukung TypeScript natively — gunakan generics collection<Interface>() untuk type safety penuh di seluruh operasi CRUD.
  • Singleton koneksi dengan connection pool adalah pola yang wajib — jangan buat koneksi baru untuk setiap operasi.
  • Gunakan $set bukan replaceOne untuk update parsial agar field lain tidak terhapus secara tidak sengaja.
  • Aggregation pipeline adalah cara terbaik memproses data di sisi database — manfaatkan $match, $group, $lookup, dan $facet untuk laporan dan transformasi data kompleks.
  • Index adalah kunci performa — buat index untuk semua field yang sering dijadikan filter; gunakan explain() untuk memverifikasi apakah query menggunakan index.
  • Transaksi hanya tersedia di replica set — gunakan untuk operasi yang harus bersifat atomik, seperti transfer atau debit/kredit.
  • Tangkap error spesifik seperti MongoServerError kode 11000 untuk duplicate key, dan implementasikan retry logic untuk error sementara seperti connection timeout.
  • Soft delete lebih aman daripada hard delete di production — tambahkan field deletedAt dan filter di setiap query daripada menghapus dokumen secara permanen.

← Sebelumnya: NoSQL Overview   Berikutnya: Elasticsearch →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact