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.
Menurunkan pertanyaan dari kebutuhan domain#
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.
- Berapa total pengeluaran pada satu hari tertentu?
- Pengeluaran apa saja yang tercatat pada rentang tanggal tertentu?
- Berapa total pengeluaran per kategori selama satu bulan?
- Kategori mana yang pengeluaran bulanannya melampaui batas yang ditetapkan?
- 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.
| Field | Koleksi | Tipe BSON | Peran |
|---|---|---|---|
tanggal | pengeluaran | date | penanda hari transaksi untuk penyaringan rentang |
kategori | pengeluaran | string | kunci alami yang merujuk kategori.nama |
jumlah | pengeluaran | Decimal128 | nilai rupiah yang wajib eksak |
catatan | pengeluaran | string, opsional | keterangan bebas milik transaksi |
nama | kategori | string | identitas kategori |
batasBulanan | kategori | Decimal128 | plafon 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.
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.
use pencatatanswitched to db pencatatandb.createCollection("kategori", {
validator: {
$jsonSchema: {
bsonType: "object",
required: [ "nama", "batasBulanan" ],
properties: {
nama: { bsonType: "string" },
batasBulanan: { bsonType: "decimal" }
}
}
}
}){ 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.
db.createCollection("pengeluaran", {
validator: {
$jsonSchema: {
bsonType: "object",
required: [ "tanggal", "kategori", "jumlah" ],
properties: {
tanggal: { bsonType: "date" },
kategori: { bsonType: "string" },
jumlah: { bsonType: "decimal" },
catatan: { bsonType: "string" }
}
}
}
}){ 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.
try {
db.pengeluaran.insertOne({ kategori: "makan", jumlah: NumberDecimal("25000") })
} catch (e) {
print("ditolak dengan kode " + e.code + ": " + e.errmsg)
}ditolak dengan kode 121: Document failed validationPesan 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.
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())Kategori terdaftar: 5const 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())Dokumen pengeluaran: 8Pertanyaan 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.
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 })[
{
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.
db.pengeluaran.aggregate([
{ $match: { tanggal: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-06-02") } } },
{ $group: { _id: null, total: { $sum: "$jumlah" } } }
])[ { _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.
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 } }
])[
{ _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.
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 } }
])[
{
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
pengeluaranmenyimpan satu dokumen per transaksi, sedangkan koleksikategorimenyimpan nama dan batas bulanan yang dirujuk melalui kunci alami. - Validator
$jsonSchemamenegakkan struktur di server, termasukjumlahbertipe Decimal128 yang menolak double. - Kelima pertanyaan domain diuji dengan
finddan pipeline agregasi di atas data contoh sebelum aplikasi dibangun.
Referensi#
- Data Modeling Introduction — MongoDB Manual — Pendekatan pemodelan data MongoDB yang berangkat dari kebutuhan aplikasi.
- Schema Validation — MongoDB Manual
— Aturan
$jsonSchemasebagai validator koleksi. - BSON Types — MongoDB Manual — Daftar tipe BSON, termasuk date dan Decimal128.
- $lookup — MongoDB Manual — Dokumentasi penggabungan koleksi di dalam pipeline.
- db.createCollection() — MongoDB Manual
— Opsi pembuatan koleksi beserta
validator.