Pemodelan domain

Merancang schema MongoDB dari kebutuhan domain, dari daftar pertanyaan sampai koleksi final.

Daftar isi

Merancang schema MongoDB dimulai dari kebutuhan domain, bukan dari daftar koleksi yang terlintas lebih dahulu. Topik ini membuka proyek penutup jalur, yaitu aplikasi pencatatan pengeluaran pribadi harian. Pertanyaan yang harus dijawab aplikasi diturunkan menjadi keputusan koleksi, field, tipe data, dan validator. Seluruh contohnya bekerja pada basis data pencatatan. Hasil akhir topik ini berupa dua koleksi final bernama pengeluaran dan kategori beserta aturan validasinya di server.

Aplikasi pencatatan pengeluaran hanya berguna apabila menjawab pertanyaan nyata, sehingga daftar pertanyaan itu ditetapkan sebelum satu field pun ditulis. Pada desain schema, daftar ini berperan sebagai daftar pola akses, yaitu bentuk pembacaan yang paling sering terjadi setelah aplikasi berjalan. Untuk proyek ini, aplikasi harus mampu menjawab lima pertanyaan berikut.

  1. Berapa total pengeluaran pada satu hari tertentu?
  2. Pengeluaran apa saja yang tercatat pada rentang tanggal tertentu?
  3. Berapa total pengeluaran per kategori selama satu bulan?
  4. Kategori mana yang pengeluaran bulanannya melampaui batas yang ditetapkan?
  5. Kategori apa saja yang terdaftar beserta batas bulanannya?

Setiap pertanyaan menunjuk field yang harus tersimpan. Pertanyaan pertama sampai ketiga menuntut tanggal, kategori, dan jumlah pada setiap transaksi, sedangkan pertanyaan keempat menuntut agar batas bulanan setiap kategori tersimpan pada satu tempat. Catatan transaksi tidak dipakai oleh pertanyaan mana pun, tetapi tetap dipertahankan sebagai field opsional karena konteksnya berguna ketika catatan lama ditinjau ulang.

Bentuk dokumen juga mengikuti daftar yang sama. Satu transaksi pengeluaran disimpan sebagai satu dokumen, bukan sebagai elemen array di dalam dokumen bulanan. Penyimpanan per transaksi menjaga dokumen tetap kecil dan pertumbuhannya wajar, sedangkan array bulanan akan terus membesar dan memaksa penulisan ulang dokumen yang sama sepanjang bulan. Pola per transaksi juga membuat penyisipan baru berupa satu operasi insert sederhana tanpa menyentuh dokumen lain.

Keputusan koleksi dan field#

Dua koleksi menjawab seluruh daftar pertanyaan. Koleksi pengeluaran menyimpan satu dokumen untuk setiap transaksi, sedangkan koleksi kategori menyimpan daftar kategori beserta batas bulanannya. Kategori dipisah dari dokumen pengeluaran karena batasnya perlu tersimpan satu tempat, bukan disalin ke setiap transaksi yang pernah memakai kategori tersebut. Dengan pemisahan ini, batas dapat diperbarui pada satu dokumen tanpa menyentuh koleksi pengeluaran sama sekali.

Pada dokumen pengeluaran, kategori disimpan sebagai teks nama, misalnya makan, yang merujuk field nama pada koleksi kategori. Nama kategori bersifat unik dan jarang berubah, sehingga berfungsi sebagai kunci alami (natural key), yaitu field yang mengidentifikasi datanya sendiri tanpa pengenal sintetis, yakni pengenal yang dibuat otomatis seperti ObjectId. Tahap $lookup kemudian menyambungkan kedua koleksi melalui nama tersebut saat pertanyaan keempat dijawab. Bila kategori dapat berganti nama, referensi berupa ObjectId lebih tepat, tetapi untuk daftar kecil yang stabil referensi berupa nama lebih mudah dibaca saat query diperiksa langsung.

Tabel berikut merangkum field kedua koleksi beserta tipe BSON dan perannya.

FieldKoleksiTipe BSONPeran
tanggalpengeluarandatepenanda hari transaksi untuk penyaringan rentang
kategoripengeluaranstringkunci alami yang merujuk kategori.nama
jumlahpengeluaranDecimal128nilai rupiah yang wajib eksak
catatanpengeluaranstring, opsionalketerangan bebas milik transaksi
namakategoristringidentitas kategori
batasBulanankategoriDecimal128plafon pengeluaran per bulan

Nilai jumlah memakai Decimal128, bukan double, karena nilainya moneter dan perhitungan uang dengan double menyisakan beda kecil. Tipe date dipilih untuk tanggal agar penyaringan rentang dan pengurutan mengikuti urutan waktu sungguhan, bukan perbandingan teks. Diagram berikut merangkum kedua koleksi beserta referensi di antara keduanya.

koleksi pengeluarantanggal (date)kategori (string)jumlah (Decimal128)catatan (string, opsional)koleksi kategorinama (string)batasBulanan (Decimal128)referensi
Skema dua koleksi aplikasi pencatatan

Membuat koleksi dengan validator#

Keputusan struktur hanya berarti apabila server menegakkannya, sehingga kedua koleksi dibuat melalui db.createCollection dengan opsi validator. Aturan $jsonSchema berikut menuntut nama dan batasBulanan pada setiap dokumen kategori. Mulailah dengan memilih basis data kerja, lalu buat kedua koleksinya satu per satu.

mongosh
use pencatatan
text Keluaran
switched to db pencatatan
mongosh
db.createCollection("kategori", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: [ "nama", "batasBulanan" ],
      properties: {
        nama: { bsonType: "string" },
        batasBulanan: { bsonType: "decimal" }
      }
    }
  }
})
text Keluaran
{ ok: 1 }

Koleksi pengeluaran memuat aturan yang lebih panjang karena fieldnya lebih banyak. Field tanggal, kategori, dan jumlah wajib ada, sedangkan catatan hanya didefinisikan pada properties tanpa masuk daftar required, sehingga validator membedakan field wajib dan opsional dengan benar.

mongosh
db.createCollection("pengeluaran", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: [ "tanggal", "kategori", "jumlah" ],
      properties: {
        tanggal: { bsonType: "date" },
        kategori: { bsonType: "string" },
        jumlah: { bsonType: "decimal" },
        catatan: { bsonType: "string" }
      }
    }
  }
})
text Keluaran
{ ok: 1 }

Kunci bsonType: "decimal" pada jumlah menutup masuknya nilai double, yaitu tipe default untuk angka pecahan pada mongosh, sehingga setiap penyisipan wajib menuliskan NumberDecimal secara eksplisit. Penegakan aturan diuji segera dengan satu dokumen yang sengaja melanggar, yaitu dokumen tanpa tanggal.

mongosh
try {
  db.pengeluaran.insertOne({ kategori: "makan", jumlah: NumberDecimal("25000") })
} catch (e) {
  print("ditolak dengan kode " + e.code + ": " + e.errmsg)
}
text Keluaran
ditolak dengan kode 121: Document failed validation

Pesan errornya menyebut kode 121, yaitu kegagalan validasi dokumen, sehingga aturan struktur telah berjalan di server sebelum satu pun data nyata masuk. Dokumen yang ditolak tidak tersimpan, sedangkan koleksi tetap siap menerima dokumen yang sesuai.

Mengisi data dan menjawab pertanyaan domain#

Data contoh diisi sebelum kelima pertanyaan diuji. Daftar kategori berisi lima dokumen dengan batas yang beragam, sedangkan pengeluaran berisi delapan transaksi yang tersebar pada lima hari pertama Juni 2026. Kedua penyisipan memakai insertMany dengan hasilnya ditampung ke variabel agar keluarannya ringkas. Penghitungan pada keluaran penyisipan kategori sekaligus menjadi jawaban pertanyaan kelima.

mongosh
const daftarKategori = [
  { nama: "makan", batasBulanan: NumberDecimal("900000") },
  { nama: "transportasi", batasBulanan: NumberDecimal("500000") },
  { nama: "belanja", batasBulanan: NumberDecimal("600000") },
  { nama: "hiburan", batasBulanan: NumberDecimal("120000") },
  { nama: "kesehatan", batasBulanan: NumberDecimal("400000") }
]
const hasilKategori = db.kategori.insertMany(daftarKategori)
print("Kategori terdaftar: " + db.kategori.countDocuments())
text Keluaran
Kategori terdaftar: 5
mongosh
const hasilPengeluaran = db.pengeluaran.insertMany([
  { tanggal: ISODate("2026-06-01"), kategori: "makan", jumlah: NumberDecimal("32000"), catatan: "sarapan dan makan siang" },
  { tanggal: ISODate("2026-06-01"), kategori: "transportasi", jumlah: NumberDecimal("14000"), catatan: "pulang-pergi kerja" },
  { tanggal: ISODate("2026-06-02"), kategori: "makan", jumlah: NumberDecimal("27000"), catatan: "makan malam" },
  { tanggal: ISODate("2026-06-02"), kategori: "hiburan", jumlah: NumberDecimal("60000"), catatan: "tiket bioskop" },
  { tanggal: ISODate("2026-06-03"), kategori: "makan", jumlah: NumberDecimal("35000"), catatan: "bahan masak" },
  { tanggal: ISODate("2026-06-03"), kategori: "belanja", jumlah: NumberDecimal("125000"), catatan: "deterjen dan sabun" },
  { tanggal: ISODate("2026-06-04"), kategori: "hiburan", jumlah: NumberDecimal("75000"), catatan: "membeli buku" },
  { tanggal: ISODate("2026-06-05"), kategori: "kesehatan", jumlah: NumberDecimal("90000"), catatan: "obat batuk" }
])
print("Dokumen pengeluaran: " + db.pengeluaran.countDocuments())
text Keluaran
Dokumen pengeluaran: 8

Pertanyaan kedua dijawab dengan find pada rentang 2 sampai 4 Juni. Proyeksi menyertakan tanggal, kategori, dan jumlah, sedangkan _id dikeluarkan agar daftarnya siap ditampilkan. Pengurutan menurun pada tanggal menaruh transaksi terbaru di baris pertama.

mongosh
db.pengeluaran.find(
  { tanggal: { $gte: ISODate("2026-06-02"), $lt: ISODate("2026-06-05") } },
  { _id: 0, tanggal: 1, kategori: 1, jumlah: 1 }
).sort({ tanggal: -1 })
text Keluaran
[
  {
    tanggal: ISODate('2026-06-04T00:00:00.000Z'),
    kategori: 'hiburan',
    jumlah: Decimal128('75000')
  },
  {
    tanggal: ISODate('2026-06-03T00:00:00.000Z'),
    kategori: 'makan',
    jumlah: Decimal128('35000')
  },
  {
    tanggal: ISODate('2026-06-03T00:00:00.000Z'),
    kategori: 'belanja',
    jumlah: Decimal128('125000')
  },
  {
    tanggal: ISODate('2026-06-02T00:00:00.000Z'),
    kategori: 'makan',
    jumlah: Decimal128('27000')
  },
  {
    tanggal: ISODate('2026-06-02T00:00:00.000Z'),
    kategori: 'hiburan',
    jumlah: Decimal128('60000')
  }
]

Pertanyaan pertama dijawab dengan pipeline pendek. $match menyaring satu hari, kemudian $group dengan _id: null mengumpulkan seluruh dokumen harian ke satu kelompok untuk dijumlahkan.

mongosh
db.pengeluaran.aggregate([
  { $match: { tanggal: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-06-02") } } },
  { $group: { _id: null, total: { $sum: "$jumlah" } } }
])
text Keluaran
[ { _id: null, total: Decimal128('46000') } ]

Total 46000 merupakan penjumlahan 32000 dan 14000, yaitu dua transaksi tanggal 1 Juni, dan hasilnya bertipe Decimal128 sebagaimana kedua nilai yang dijumlahkan. Pertanyaan ketiga memakai bentuk $group yang sama, tetapi kunci kelompoknya kini kategori, ditambah akumulator $sum: 1 yang menghitung frekuensi transaksi.

mongosh
db.pengeluaran.aggregate([
  { $match: { tanggal: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-07-01") } } },
  { $group: { _id: "$kategori", total: { $sum: "$jumlah" }, frekuensi: { $sum: 1 } } },
  { $sort: { _id: 1 } }
])
text Keluaran
[
  { _id: 'belanja', total: Decimal128('125000'), frekuensi: 1 },
  { _id: 'hiburan', total: Decimal128('135000'), frekuensi: 2 },
  { _id: 'kesehatan', total: Decimal128('90000'), frekuensi: 1 },
  { _id: 'makan', total: Decimal128('94000'), frekuensi: 3 },
  { _id: 'transportasi', total: Decimal128('14000'), frekuensi: 1 }
]

Pertanyaan keempat membutuhkan batas dari koleksi kategori, sehingga pipeline menambahkan $lookup lalu $unwind untuk mengubah array hasil penggabungan menjadi satu dokumen. $match dengan $expr membandingkan total dengan batas, kemudian $project membentuk dokumen akhir yang ringkas.

mongosh
db.pengeluaran.aggregate([
  { $match: { tanggal: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-07-01") } } },
  { $group: { _id: "$kategori", total: { $sum: "$jumlah" } } },
  { $lookup: { from: "kategori", localField: "_id", foreignField: "nama", as: "info" } },
  { $unwind: "$info" },
  { $match: { $expr: { $gt: [ "$total", "$info.batasBulanan" ] } } },
  { $project: { _id: 0, kategori: "$_id", total: 1, batas: "$info.batasBulanan" } },
  { $sort: { kategori: 1 } }
])
text Keluaran
[
  {
    total: Decimal128('135000'),
    kategori: 'hiburan',
    batas: Decimal128('120000')
  }
]

Hanya kategori hiburan yang melampaui batasnya, yaitu 135000 terhadap batas 120000. Kelima pertanyaan domain kini terjawab oleh query yang pendek, sehingga desainnya layak menjadi fondasi aplikasi. Data contoh pada topik ini juga berlaku sebagai pembanding ketika aplikasi CRUD dibangun di atas koleksi yang sama.

Ringkasan#

  • Desain schema dimulai dari daftar pertanyaan domain, dan setiap pertanyaan menunjuk field serta pola akses yang diperlukan.
  • Koleksi pengeluaran menyimpan satu dokumen per transaksi, sedangkan koleksi kategori menyimpan nama dan batas bulanan yang dirujuk melalui kunci alami.
  • Validator $jsonSchema menegakkan struktur di server, termasuk jumlah bertipe Decimal128 yang menolak double.
  • Kelima pertanyaan domain diuji dengan find dan pipeline agregasi di atas data contoh sebelum aplikasi dibangun.

Referensi#