Arsitektur adalah cara membagi kode ke beberapa file dan lapisan, supaya tiap bagian punya satu tugas. Bayangin warung makan yang tumbuh. Awalnya satu orang mengerjakan semua: menerima pesanan, memasak, mengambil bahan di gudang, dan mengantar piring. Begitu ramai, tugasnya dibagi: kasir menerima pesanan dan meneruskannya, pelayan mencatat pesanan dan mengantar piring, koki memasak sesuai resep, penjaga gudang mengambil bahan. Koki gak perlu tahu nomor meja, pelayan gak perlu tahu letak rak di gudang. Di Express, keempatnya adalah route, controller, service, dan repository.
🧪 Tebakan cepat:
catatan-apisudah dipecah jadi empat lapisan. Untuk testing, kamu mau datanya diambil dari array di memori, bukan dari filecatatan.db. Berapa file yang harus diubah? Tebak dulu, jawabannya ada di bagian "Ganti penyimpanan, satu file berubah".
Sebelum: semua di satu handler
Di pelajaran database, route catatan-api mengerjakan semuanya sendiri:
app.get("/catatan/:id", (req, res) => {
const row = db.prepare("SELECT * FROM catatan WHERE id = ?").get(Number(req.params.id));
if (!row) return res.status(404).json({ error: "Catatan tidak ditemukan" });
res.json(row);
});
Satu handler tahu URL-nya, membaca req, menulis SQL, memilih status, dan mengirim JSON. Dengan dua route masih enak dibaca. Dengan dua puluh route, SQL yang sama tersalin di banyak tempat, dan aturan bisnis tersebar di mana-mana.
Empat lapisan dan apa yang boleh mereka tahu
| Lapisan | File | Tugasnya | Boleh tahu | Gak boleh tahu |
|---|---|---|---|---|
| Route | routes/catatan.js |
URL dan method ke function controller | Express Router | data, SQL |
| Controller | controllers/catatanController.js |
baca req, panggil service, pilih status dan JSON |
req, res, status code |
SQL, tabel |
| Service | services/catatanService.js |
aturan bisnis | data catatan, aturannya | req, res, status code, SQL |
| Repository | repositories/catatanRepository.js |
ambil dan simpan data | SQL, db |
HTTP, aturan bisnis |
Tiap lapisan cuma memanggil lapisan di bawahnya: route ke controller, controller ke service, service ke repository.
Refactor-nya, dari bawah ke atas
// repositories/catatanRepository.js: cuma urusan data
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync("catatan.db");
async function semua() {
return db.prepare("SELECT * FROM catatan").all();
}
async function cariById(id) {
return db.prepare("SELECT * FROM catatan WHERE id = ?").get(id) ?? null;
}
module.exports = { semua, cariById };
// services/catatanService.js: aturan bisnis, tanpa req dan res
const repo = require("../repositories/catatanRepository");
async function daftar() {
const semua = await repo.semua();
return [...semua].sort((a, b) => a.judul.localeCompare(b.judul)); // aturan: urut judul
}
async function ambil(id) {
return repo.cariById(id); // catatan, atau null kalau gak ada
}
module.exports = { daftar, ambil };
// controllers/catatanController.js: HTTP masuk, HTTP keluar
const service = require("../services/catatanService");
async function daftar(req, res) {
res.json(await service.daftar());
}
async function detail(req, res) {
const catatan = await service.ambil(Number(req.params.id));
if (!catatan) return res.status(404).json({ error: "Catatan tidak ditemukan" });
res.json(catatan);
}
module.exports = { daftar, detail };
// routes/catatan.js: cuma peta URL
const express = require("express");
const controller = require("../controllers/catatanController");
const router = express.Router();
router.get("/", controller.daftar);
router.get("/:id", controller.detail);
module.exports = router;
// app.js: merakit semuanya
const express = require("express");
const catatanRouter = require("./routes/catatan");
const app = express();
app.use(express.json());
app.use("/api/catatan", catatanRouter);
app.listen(3000);
- Repository mengembalikan data mentah, atau
nullkalau gak ada. Function-nyaasyncwalaunode:sqlitesinkron, supaya penggantinya yang async (misalnya PostgreSQL) gak mengubah lapisan di atasnya. - Service memegang aturan "daftar diurutkan dari judul". Hasilnya
BelajarsebelumBelanja. - Controller yang memutuskan "gak ada" berarti 404. Service gak tahu apa itu status code.
express.Router dan app.use: alamat lengkapnya
express.Router() membuat kumpulan route yang bisa dipasang ke aplikasi utama. app.use("/api/catatan", catatanRouter) menempelkan awalan /api/catatan ke semua route di dalamnya: router.get("/") melayani GET /api/catatan, dan router.get("/:id") melayani GET /api/catatan/2. Alamat lengkap = awalan di app.use + path di router. Router yang dipasang di /api/siswa dengan router.get("/") melayani /api/siswa.
Ganti penyimpanan, satu file berubah
Untuk testing, ganti isi repositories/catatanRepository.js saja:
const catatan = [
{ id: 1, judul: "Belanja", isi: "beli beras 5 kg" },
{ id: 2, judul: "Belajar", isi: "bab Node.js" },
];
async function semua() {
return catatan;
}
async function cariById(id) {
return catatan.find((c) => c.id === id) ?? null;
}
module.exports = { semua, cariById };
Nama function dan bentuk hasilnya sama, jadi service, controller, dan route gak berubah sama sekali. Jawabannya satu file. Itu jawaban tebakannya. Tanpa lapisan, kamu harus mencari dan mengganti setiap db.prepare di semua handler.
MVC dan pola ini
MVC (Model-View-Controller) adalah pola lama yang memisahkan data (model), tampilan (view), dan penghubung keduanya (controller). Di API, "view"-nya adalah JSON yang dikirim res.json. Route, controller, service, repository adalah turunan MVC: model dipecah jadi service (aturan) dan repository (data).
Di latihan: userService dan userController
Kode awal punya users (Budi, Siti, Andi), userService.getAll(), dan userController.list(). Lengkapi userService.getById(id) dengan users.find((u) => u.id === id) || null: service cuma mencari dan mengembalikan, gak mencetak apa pun. Di userController.detail(id), cetak "GET /users/" + id + " -> " + JSON.stringify(user) kalau ketemu, atau "GET /users/" + id + " -> 404 Not Found" kalau gak.
⚠️ Jebakan yang sering ketemu
- SQL di controller. Begitu ada dua controller yang butuh data yang sama, SQL-nya tersalin. Taruh di repository.
reqatauresdi service. Service jadi gak bisa dipanggil dari script, cron, atau test. Kirim nilainya saja, misalnyaservice.ambil(Number(req.params.id)).- Service yang mencetak, bukan mengembalikan.
console.logdi service gak bisa dipakai controller. Kembalikan datanya, biar controller yang memutuskan jawabannya. - Path
requireyang salah. Daricontrollers/, service ada di../services/catatanService. Salah satu titik:Error: Cannot find module. - Lapisan berlebihan untuk aplikasi kecil. Tiga route dan satu tabel boleh tetap di satu file. Pecah saat mulai terasa sesak.
- Rahasia di dalam kode. Konfigurasi seperti
PORTdanJWT_SECRETdibaca dari environment variable, dan.envmasuk.gitignore.
TL;DR: Pecah aplikasi Express jadi empat lapisan yang masing-masing punya satu tugas: route (peta URL, express.Router()), controller (baca req, pilih status, kirim JSON), service (aturan bisnis, tanpa req/res), repository (SQL dan data). Tiap lapisan cuma memanggil lapisan di bawahnya, jadi mengganti penyimpanan cukup mengubah repository. app.use("/api/catatan", router) memasang router, dan alamat lengkapnya awalan itu ditambah path di router. Pola ini turunan MVC (Model-View-Controller). Service mengembalikan data atau null; controller yang memutuskan 404.