Skrip latihan satu berkas memang praktis: node latihan.js, selesai. Masalah muncul ketika skrip itu tumbuh menjadi aplikasi sungguhan — koneksi, query, aturan bisnis, dan handler HTTP berdesakan di satu berkas ratusan baris, dan setiap perubahan kecil berisiko menyentuh bagian yang tidak berhubungan. Section ini membangun aplikasi Node.js di atas MongoDB dengan bertahap: bab ini menata strukturnya lebih dulu, bab berikutnya mengisi tiap lapisan dengan driver resmi, lalu dengan Mongoose. Semua contoh memakai paket yang sudah terpasang di lingkungan latihan kita — tanpa dependency baru.
Peta materi bab ini:
- Pohon direktori aplikasi dan tanggung jawab tiap lapisan.
- Konfigurasi terpusat lewat environment variable.
- Modul koneksi tunggal dan lapisan model.
- Lapisan service, entry point
node:http, dan skrip npm.
Pohon Direktori Aplikasi #
Aplikasi contoh kita adalah API katalog produk toko buku. Struktur berikut memisahkan kode berdasarkan alasan berubah, bukan berdasarkan tipe berkas:
toko-buku/
├── package.json
├── .env
└── src/
├── app.js ← entry point: server HTTP + startup + shutdown
├── config.js ← satu-satunya pembaca environment variable
├── db.js ← koneksi MongoDB tunggal (singleton)
├── models/
│ └── produkModel.js ← query collection produk
└── services/
└── produkService.js ← aturan bisnis produkAlur satu permintaan berjalan satu arah, lapisan demi lapisan:
Setiap lapisan hanya mengenal lapisan tepat di bawahnya. Tabel ini merangkum batasnya:
| Direktori | Tanggung jawab | Dilarang menyimpan |
|---|---|---|
src/app.js | memetakan URL ke service, membaca body, mengatur status code | query database |
src/services/ | validasi input, aturan bisnis, penyusunan respons domain | sintaks query driver |
src/models/ | filter, projection, sorting, bentuk document | keputusan bisnis |
src/db.js, src/config.js | koneksi dan konfigurasi | logika aplikasi |
Pemisahan ini yang membuat aplikasi bisa tumbuh: mengganti driver dengan Mongoose pada bab berikutnya hanya menyentuh models/ dan db.js, sedangkan services/ dan app.js tidak berubah.
Konfigurasi Terpusat #
Connection string dan nomor port adalah data lingkungan, bukan bagian logika — keduanya berbeda antara laptop, staging, dan produksi. Node.js versi 24 dapat memuat berkas .env tanpa paket tambahan lewat flag --env-file:
MONGODB_URI=mongodb://127.0.0.1:27017/?directConnection=true
APP_DB=latihan_node_struktur
PORT=3000Seluruh pembacaan environment variable dikumpulkan di satu modul:
// src/config.js — satu-satunya tempat membaca environment variable
const config = {
mongodbUri:
process.env.MONGODB_URI ??
"mongodb://127.0.0.1:27017/?directConnection=true",
dbName: process.env.APP_DB ?? "latihan_node_struktur",
port: Number(process.env.PORT ?? 3000),
}
module.exports = configBaris ?? memberi nilai cadangan agar aplikasi tetap jalan tanpa .env — berguna saat uji cepat. Skrip npm kemudian membungkus flag dan entry point:
{
"name": "toko-buku",
"scripts": {
"dev": "node --watch --env-file=.env src/app.js",
"start": "node --env-file-if-exists=.env src/app.js"
}
}Skrip dev memakai --watch yang memuat ulang proses setiap berkas berubah; skrip start dipakai di server produksi dan tidak memakai --watch.
Koneksi Tunggal dan Lapisan Model #
Driver mongodb dirancang untuk satu MongoClient per proses — kumpulan koneksinya (connection pool) dibagi seluruh permintaan. Modul berikut menjaga aturan itu:
// src/db.js — koneksi tunggal (singleton) untuk seluruh aplikasi
const { MongoClient } = require("mongodb")
const config = require("./config")
let client = null
async function connect() {
if (client) return client // sudah terhubung — kembalikan client yang sama
client = new MongoClient(config.mongodbUri)
await client.connect()
return client
}
function getDb() {
if (!client) {
throw new Error("Panggil connect() lebih dulu sebelum getDb()")
}
return client.db(config.dbName)
}
async function close() {
if (client) {
await client.close()
client = null
}
}
module.exports = { connect, getDb, close }Variabel client di luar fungsi membuat status koneksi dibagi ke semua pemanggil modul. Aturan “satu MongoClient per proses” punya alasan kuat: setiap client membuka connection pool sendiri, sehingga client baru per request membuat gelombang handshake baru dan memakan slot koneksi server (kita bahas dampaknya di Chapter Connection Pooling). Fungsi connect() juga bersifat idempoten — dipanggil dua kali pun client yang sama yang dikembalikan. Lapisan model memakai getDb() tanpa pernah membuka koneksi sendiri:
// src/models/produkModel.js — satu-satunya file yang menyentuh collection produk
const { getDb } = require("../db")
function koleksi() {
return getDb().collection("produk")
}
async function daftarProduk() {
return koleksi()
.find({}, { projection: { _id: 0, kode: 1, nama: 1, harga: 1 } })
.sort({ nama: 1 })
.toArray()
}
async function hitungProduk() {
return koleksi().countDocuments()
}
async function tambahProduk({ kode, nama, harga }) {
const hasil = await koleksi().insertOne({ kode, nama, harga })
return { kode, nama, harga, id: hasil.insertedId }
}
module.exports = { daftarProduk, hitungProduk, tambahProduk }Perhatikan bahwa db.js berada di src/ sedangkan model memakai require("../db") — struktur direktori mengikuti lapisan, dan path relatif membuat dependensi eksplisit. Ketiga fungsi model sengaja memakai nama domain (daftarProduk, tambahProduk), bukan nama operasi driver; pemanggil tidak perlu tahu bahwa di dalamnya ada find() dengan projection kode dan nama.
Lapisan Service dan Entry Point #
Service menambahkan aturan bisnis di atas model: validasi nama, harga positif, dan pembuatan kode produk berurutan. Galat validasi diberi statusCode agar lapisan HTTP tinggal meneruskannya:
// src/services/produkService.js — aturan bisnis; tidak tahu detail query
const produkModel = require("../models/produkModel")
async function listProduk() {
return produkModel.daftarProduk()
}
async function buatProduk({ nama, harga }) {
if (typeof nama !== "string" || nama.trim().length < 3) {
const galat = new Error("Nama produk wajib minimal 3 karakter")
galat.statusCode = 400
throw galat
}
if (!Number.isFinite(harga) || harga <= 0) {
const galat = new Error("Harga harus berupa angka positif")
galat.statusCode = 400
throw galat
}
const nomor = (await produkModel.hitungProduk()) + 1
const kode = `PRD-${String(nomor).padStart(3, "0")}`
return produkModel.tambahProduk({ kode, nama: nama.trim(), harga })
}
module.exports = { listProduk, buatProduk }Entry point merangkai semuanya dengan node:http bawaan — tanpa kerangka web, karena fokus kita adalah pola strukturnya:
// src/app.js — entry point: server HTTP tipis di atas lapisan service
const http = require("node:http")
const config = require("./config")
const db = require("./db")
const produkService = require("./services/produkService")
function bacaBody(req) {
return new Promise((resolve, reject) => {
let data = ""
req.on("data", (potongan) => (data += potongan))
req.on("end", () => resolve(data))
req.on("error", reject)
})
}
async function main() {
await db.connect() // gagal lebih awal saat boot, bukan di request pertama
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
res.setHeader("content-type", "application/json")
try {
if (req.method === "GET" && url.pathname === "/api/produk") {
res.end(JSON.stringify(await produkService.listProduk()))
return
}
if (req.method === "POST" && url.pathname === "/api/produk") {
const body = JSON.parse(await bacaBody(req))
const produk = await produkService.buatProduk(body)
res.statusCode = 201
res.end(JSON.stringify(produk))
return
}
res.statusCode = 404
res.end(JSON.stringify({ error: "Endpoint tidak ditemukan" }))
} catch (err) {
res.statusCode = err.statusCode ?? 500
res.end(JSON.stringify({ error: err.message }))
}
})
server.listen(config.port, () => {
console.log(`Server berjalan di http://localhost:${config.port}`)
})
const tutupAplikasi = async () => {
await db.close()
server.close(() => process.exit(0))
}
process.on("SIGINT", tutupAplikasi)
process.on("SIGTERM", tutupAplikasi)
}
main().catch((err) => {
console.error("Gagal menjalankan aplikasi:", err.message)
process.exit(1)
})Kita jalankan dari akar repositori (tempat node_modules berada), lalu menguji empat skenario dari terminal kedua:
cd /home/z/my-project/tutorial
MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" APP_DB=latihan_node_struktur PORT=3311 node content/mongodb/mongodb-dengan-nodejs/struktur-aplikasi-nodejs/src/app.jscurl -s http://localhost:3311/api/produk
curl -s -X POST http://localhost:3311/api/produk -H 'content-type: application/json' -d '{"nama":"Kopi Gayo","harga":85000}'
curl -s -X POST http://localhost:3311/api/produk -H 'content-type: application/json' -d '{"nama":"Teh Melati","harga":32000}'
curl -s -X POST http://localhost:3311/api/produk -H 'content-type: application/json' -d '{"nama":"Ko","harga":1000}'
curl -s http://localhost:3311/api/produkOutput yang Dihasilkan (server mencetak Server berjalan di http://localhost:3311, lalu empat respons berikut muncul berurutan):
[]
{"kode":"PRD-001","nama":"Kopi Gayo","harga":85000,"id":"6ad3f38bd26a25ea711765a3"}
{"kode":"PRD-002","nama":"Teh Melati","harga":32000,"id":"6ad3f38bd26a25ea711765a4"}
{"error":"Nama produk wajib minimal 3 karakter"}
[{"kode":"PRD-001","nama":"Kopi Gayo","harga":85000},{"kode":"PRD-002","nama":"Teh Melati","harga":32000}]Mari kita telusuri hasilnya: GET pertama mengembalikan array kosong karena collection masih baru. Dua POST valid menembus service (kode PRD-001 dan PRD-002 dihasilkan dari hitungProduk() + 1), model menyisipkan document, dan respons membawa id hasil insertedId. POST ketiga ditolak service sebelum menyentuh database — pesan galatnya diteruskan dengan status 400 oleh handler di app.js. GET terakhir membuktikan dua produk tersimpan dan diurutkan berdasarkan nama.
Dua detail eksekusi layak dicermati. Pertama, await db.connect() diletakkan sebelum server.listen() sehingga galat koneksi muncul saat boot — proses berhenti di awal, bukan gagal diam-diam pada permintaan pertama. Kedua, penanganan SIGINT/SIGTERM menutup koneksi database sebelum server berhenti; tanpa itu, Ctrl+C meninggalkan koneksi terbuka di sisi server sampai timeout. Kode produk juga bersifat turunan dari jumlah document, jadi mengulang latihan tanpa membersihkan collection akan melanjutkan nomor dari data lama.
Rangkuman #
- Struktur
src/memisahkan aplikasi menjadi empat tanggung jawab: route HTTP, service (aturan bisnis), model (query), dan koneksi/konfigurasi — alur dependensi satu arah. - Environment variable dikumpulkan di
config.js; Node.js 24 memuatnya dari.envlewat--env-filetanpa paket tambahan. db.jsmenjaga satuMongoClientper proses dengan variabel modul sebagai penyimpan status koneksi.node:httpbawaan cukup untuk contoh server; kerangka web hanya mengganti lapisan paling luar, bukan lapisan model dan service.