Struktur Folder Express 4 yang Rapi untuk Proyek Jangka Panjang

Struktur Folder Express 4 yang Rapi untuk Proyek Jangka Panjang

Titik ketika routes/index.js berhenti bisa dibaca

Proyek dimulai dari express-generator. Empat file di routes/, semuanya masuk akal. Delapan belas bulan kemudian file yang sama berisi 1.400 baris: query MySQL, validasi form, pengiriman email, tiga fungsi helper yang dipanggil dari satu tempat, dan sebuah res.render dengan objek data 40 properti.

Gejalanya bukan "kodenya jelek". Gejalanya lebih spesifik:

  • Menambah satu halaman butuh membaca 300 baris untuk tahu di mana menyisipkannya.
  • Dua developer menyentuh file yang sama untuk fitur yang tidak berhubungan, dan setiap merge jadi konflik.
  • Perubahan pada query blog tidak sengaja mengubah tampilan halaman kontak, karena keduanya berbagi satu helper yang pelan-pelan tumbuh jadi serba bisa.
  • Tidak ada yang berani menghapus apa pun, karena tidak jelas siapa memanggil apa.

Artikel ini membedah struktur yang bertahan setelah 20, 50, dan 100 route: pemisahan page router dan service router, lapisan data yang terpisah dari route, konvensi penamaan yang tidak perlu didiskusikan lagi, dan cara migrasi tanpa membekukan pengembangan fitur selama dua sprint.

Kenapa express-generator berhenti relevan setelah 20 route

Generator bawaan menghasilkan ini:

app.js
bin/www
routes/
  index.js
  users.js
views/
public/

Struktur itu benar untuk demo. Masalahnya muncul dari satu asumsi diam-diam: satu file route menangani satu prefix URL, dan semua logika di dalamnya. Selama prefix-nya sedikit, asumsi itu tidak terasa. Begitu aplikasi punya halaman publik, endpoint JSON, form POST, panel admin, dan webhook, satu dimensi pemisahan tidak cukup.

Perhatikan dua route berikut. Keduanya sering ada di file yang sama, padahal karakternya berbeda total:

// A — mengembalikan HTML, punya layout, punya meta SEO, di-cache browser
router.get('/blog/:slug', async (req, res) => { ... res.render('pages/blog-detail', data); });

// B — mengembalikan JSON, dipanggil fetch(), tidak boleh di-cache, butuh auth
router.post('/blog/save', requireAuth, async (req, res) => { ... res.json({ ok: true }); });

Perbedaannya bukan gaya. Beda kontrak error (halaman error vs { error }), beda kebutuhan auth, beda strategi cache, beda konsumen. Menyimpannya di satu file berarti setiap perubahan pada salah satu memaksa kamu membaca yang lain.

Aturan yang dipakai di proyek ini: satu file router untuk satu path level atas, dan dua tingkat router yang dipisah berdasarkan apa yang dikembalikan.

Memisahkan page router dan service router

Struktur targetnya:

routes/
  index.js          GET /              -> render pages/index.hbs
  about.js          GET /about
  blog.js           GET /blog, /blog/:slug
  works.js          GET /works
  admin.js          GET /admin/*       (semua layar panel)
  services/
    authService.js       POST /services/auth/login, /logout
    blogService.js       POST /services/blog/save, /delete
    contactService.js    POST /services/contact
    uploadService.js     POST /services/upload

Mounting-nya terpusat, satu blok, di app.js:

// Page routers — mengembalikan HTML
app.use('/', require('./routes/index'));
app.use('/about', require('./routes/about'));
app.use('/blog', require('./routes/blog'));
app.use('/works', require('./routes/works'));
app.use('/admin', require('./routes/admin'));

// Service routers — mengembalikan JSON atau redirect setelah POST
app.use('/services/auth', require('./routes/services/authService'));
app.use('/services/blog', require('./routes/services/blogService'));
app.use('/services/contact', require('./routes/services/contactService'));
app.use('/services/upload', require('./routes/services/uploadService'));

Yang kamu dapat dari pemisahan ini, konkret:

Satu tempat untuk kebijakan lintas-route. Ingin semua endpoint service mengembalikan JSON saat error, bukan halaman HTML? Satu middleware, satu prefix:

app.use('/services', (req, res, next) => {
  res.locals.wantsJson = true;
  next();
});

Auth tidak lagi tersebar. Semua route admin ada di bawah dua prefix. Audit "apakah setiap layar admin dilindungi" jadi pembacaan dua file, bukan grep ke seluruh repo.

Nama file memprediksi URL. routes/works.js menangani /works. Tidak ada yang perlu bertanya di channel tim.

Satu jebakan yang perlu diingat: mounting app.use('/blog', router) membuat path di dalam router menjadi relatif. Route detail ditulis router.get('/:slug'), bukan router.get('/blog/:slug'). Menulis path penuh setelah dimount menghasilkan /blog/blog/:slug — 404 yang membingungkan karena file-nya jelas ada.

Lapisan repository: memindahkan SQL keluar dari file route

Ini pemisahan kedua, dan yang paling banyak menghemat waktu.

Sebelum — SQL menempel di handler:

router.get('/:slug', (req, res) => {
  db.query(
    'SELECT * FROM blogs WHERE slug = ? AND (status = "published" OR (status = "scheduled" AND published_at <= NOW()))',
    [req.params.slug],
    (err, rows) => {
      if (err) return res.status(500).render('error', { message: err.message });
      if (!rows.length) return res.status(404).render('404');
      res.render('pages/blog-detail', { post: rows[0] });
    }
  );
});

Query yang sama — dengan kondisi "artikel ini boleh tampil" — akan muncul lagi di daftar blog, di sitemap, di RSS, dan di widget artikel terkait. Empat salinan. Ketika aturannya berubah (misalnya menambah status archived), tiga di antaranya akan lupa diubah, dan drafnya bocor ke publik lewat sitemap.

Sesudah — satu modul memiliki aturannya:

// utils/articles.js
const LIVE_CONDITION = `(status = 'published' OR (status = 'scheduled' AND published_at <= NOW()))`;

async function findBySlug(slug) {
  const [rows] = await db.execute(
    `SELECT * FROM blogs WHERE slug = ? AND ${LIVE_CONDITION} LIMIT 1`,
    [slug]
  );
  return rows[0] || null;
}

async function listLive({ limit = 10, offset = 0 } = {}) {
  const [rows] = await db.execute(
    `SELECT id, title, slug, excerpt, thumbnail, published_at
       FROM blogs WHERE ${LIVE_CONDITION}
       ORDER BY published_at DESC LIMIT ? OFFSET ?`,
    [limit, offset]
  );
  return rows;
}

module.exports = { LIVE_CONDITION, findBySlug, listLive };

Route-nya menyusut jadi apa yang memang tugasnya — memetakan HTTP ke tampilan:

router.get('/:slug', async (req, res, next) => {
  try {
    const post = await articles.findBySlug(req.params.slug);
    if (!post) return res.status(404).render('404');
    res.render('pages/blog-detail', { post, title: post.meta_title || post.title });
  } catch (err) {
    next(err);
  }
});

Tujuh baris. Bisa dibaca sekali lewat. Dan aturan "apa yang tayang" sekarang punya satu alamat — kalau salah, salahnya di satu tempat.

Ini bukan ORM dan bukan arsitektur berlapis-lapis. Aturan praktisnya cuma satu: string SQL tidak boleh muncul di dalam file di routes/. Selesai. Tidak perlu interface, tidak perlu dependency injection, tidak perlu folder domain/.

Isi utils/ yang khas di proyek berukuran ini:

utils/
  articles.js       aturan "live", query blog
  works.js          portofolio + cache 60 detik
  sectionContent.js konten seksi homepage yang bisa diedit
  hbsutils.js       registrasi helper Handlebars
  analytics.js      middleware pencatat kunjungan

Setiap file punya satu subjek. Kalau kamu tergoda membuat utils/helpers.js, berhenti — nama itu tidak menolak apa pun, dan dalam enam bulan isinya akan jadi 600 baris tanpa tema.

Konvensi penamaan dan mounting terpusat

Konvensi yang tidak perlu didiskusikan ulang, ditulis sekali di README atau CLAUDE.md:

HalAturanContoh
File page routerhuruf kecil, sama dengan path URLworks.js/works
Path URL Indonesia, file Inggrisfile service.js boleh mount ke /layananapp.use('/layanan', require('./routes/service'))
File service routercamelCase + sufiks ServiceblogService.js
Modul datakata benda jamak, di utils/articles.js, works.js
View halamanviews/pages/<slug>.hbsviews/pages/about.hbs
Partialviews/partials/<area>/<nama>.hbspartials/admin/sidebar.hbs
MigrasiNNN-deskripsi.sql, urut naik007-add-page-views.sql

Dua detail yang sering menggigit:

Registrasi partial dan tanda hubung. hbs.registerPartials secara default mengubah tanda hubung jadi garis bawah, sehingga media-dialog.hbs terdaftar sebagai media_dialog dan {{> admin/media-dialog}} gagal — saat render, bukan saat boot. Matikan dengan:

hbs.registerPartials(path.join(__dirname, 'views/partials'), { rename: (name) => name });

Partial didaftarkan sekali saat boot. Mengedit partial butuh restart server, bukan cuma refresh browser. Kalau nodemon kamu mengabaikan folder views/, kamu akan menghabiskan sepuluh menit menatap perubahan yang "tidak muncul".

Mounting terpusat di app.js juga menyelesaikan pertanyaan urutan sekali saja: parser body dan session di atas, static di atas router, catch-all 404 dan error handler paling bawah. Kalau setiap file router memasang middleware-nya sendiri, urutan itu jadi properti yang muncul dari kebetulan.

Migrasi bertahap tanpa membekukan fitur

Menulis ulang routes/index.js 1.400 baris dalam satu PR adalah cara paling pasti agar refactor itu tidak pernah selesai. Yang berhasil adalah aturan "boy scout" dengan urutan tetap:

Langkah 1 — pindahkan SQL dulu, jangan sentuh route. Untuk setiap fitur yang kamu sentuh minggu ini, ekstrak query-nya ke utils/<subjek>.js. Handler tetap di tempatnya. Perubahannya kecil, mudah di-review, dan tidak mengubah URL apa pun. Setelah tiga sampai empat fitur, kamu sudah punya lapisan data tanpa pernah menulis PR berjudul "refactor".

Langkah 2 — pecah per path, satu per PR. Ambil satu prefix. Buat file baru, pindahkan handler-nya apa adanya, mount di app.js, hapus dari file lama. Verifikasinya cepat karena tidak ada logika yang berubah:

# Sebelum dan sesudah harus identik
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' http://localhost:3000/works

Langkah 3 — pisahkan POST ke services/. Ini satu-satunya langkah yang mengubah URL, jadi lakukan dengan redirect sementara supaya form lama yang mungkin masih ter-cache tidak patah:

app.post('/blog/save', (req, res) => res.redirect(307, '/services/blog/save'));

Status 307, bukan 302: 307 mempertahankan method dan body POST. Redirect 302 pada POST berubah jadi GET di sebagian besar browser, dan data form-nya hilang tanpa error. Hapus shim ini setelah satu siklus deploy.

Langkah 4 — pasang pagar. Setelah struktur rapi, jaga supaya tidak balik lagi. Satu perintah di CI atau di pre-commit sudah cukup:

# Gagal bila ada SQL yang menyelinap kembali ke routes/
grep -rEn "SELECT |INSERT INTO|UPDATE |DELETE FROM" routes/ && \
  { echo "SQL di routes/ — pindahkan ke utils/"; exit 1; } || echo "OK"

Sepuluh baris shell menghentikan kemunduran yang biasanya butuh code review manual berulang untuk dicegah.

Yang bisa kamu lakukan hari ini

Urutannya sengaja dari yang paling murah:

  1. Hitung baris tiap file di routes/: wc -l routes/*.js | sort -n. File apa pun di atas 300 baris adalah kandidat pecah berikutnya.
  2. Jalankan grep SQL di atas. Jumlah hasilnya adalah ukuran utang lapisan data kamu — catat angkanya, itu jadi tolok ukur.
  3. Ambil satu fitur yang minggu ini akan kamu sentuh. Ekstrak query-nya ke utils/<subjek>.js. Jangan sentuh yang lain.
  4. Tulis tabel konvensi penamaan di README.md proyek, walau cuma lima baris. Konvensi yang tidak tertulis akan didiskusikan ulang setiap onboarding.
  5. Pindahkan satu prefix URL ke file router sendiri, dan verifikasi dengan curl bahwa status dan ukuran response tidak berubah.

Lima langkah itu muat dalam satu sore, dan tidak satu pun butuh membekukan pengembangan fitur.