Express.js terlihat simpel, tapi perbedaan kualitas aplikasi produksi ada pada detailnya: struktur, middleware, error handling, observability, dan keamanan. Artikel ini merangkum praktik yang perlu kamu kuasai untuk naik level dari intermediate ke senior.
1) Struktur project yang rapi
Minimal yang sehat:
src/
routes/
controllers/
services/
repositories/
middlewares/
lib/
app.ts
Intinya: pisahkan HTTP (routes/controllers) dari bisnis (services) dan data (repositories).
2) Middleware yang tepat sasaran
Middleware bukan hanya untuk auth. Gunakan untuk:
- logging
- validation
- rate limit
- error normalization
Contoh pipeline sederhana:
app.use(requestId())
app.use(json())
app.use(logger())
app.use(routes)
app.use(notFound())
app.use(errorHandler())3) Error handling yang konsisten
Jangan throw sembarangan. Buat error class dan standar response.
class AppError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
function errorHandler(err, req, res, next) {
const status = err.status ?? 500
res.status(status).json({
error: err.message ?? "Internal error",
})
}Intinya: semua error harus keluar dengan format konsisten.
4) Validation di boundary
Validasi input di layer HTTP. Jangan menunggu sampai service.
Pakai zod/joi/yup untuk schema:
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})Intinya: reject request sebelum menyentuh bisnis.
5) Auth & authorization jelas
Pisahkan:
- Authentication: siapa user‑nya
- Authorization: boleh akses apa
Contoh:
app.get("/admin", requireAuth, requireRole("admin"), handler)Contoh implementasi sederhana:
function requireAuth(req, res, next) {
const user = req.user
if (!user) return res.status(401).json({ error: "Unauthorized" })
next()
}
const requireRole = (role) => (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: "Forbidden" })
}
next()
}Kenapa ini penting: tanpa pemisahan auth/role, endpoint mudah bocor dan sulit diaudit saat scope user bertambah.
6) Database access: jangan campur dengan controller
Controller hanya req -> res. Semua query di repository/service.
Bad:
app.get("/users", async (req, res) => {
const users = await db.query("SELECT ...")
res.json(users)
})Good:
const users = await userService.list()
res.json(users)Contoh repository:
const userRepo = {
list: () => db.query("SELECT id, name, role FROM users"),
}Kenapa ini penting: pemisahan repo membuat query mudah diuji, di-cache, dan diganti tanpa merusak controller.
7) Observability: logs + metrics
Tambahkan:
- request id
- latency
- error rate
Minimal log format:
{ requestId, route, status, durationMs }Intinya: tanpa log yang baik, debugging di produksi itu buta.
Contoh middleware:
app.use((req, res, next) => {
const start = Date.now()
res.on("finish", () => {
console.log({
route: req.path,
status: res.statusCode,
durationMs: Date.now() - start,
})
})
next()
})Kenapa ini penting: tanpa log latency dan status, root cause error di produksi akan sulit ditemukan.
8) Performance yang benar
Hal yang sering dilupakan:
- gzip/brotli
- caching header
- rate limit
- slow query logging
Intinya: performa buruk sering datang dari query lambat dan payload berlebihan.
Contoh caching header untuk data yang jarang berubah:
res.set("Cache-Control", "public, max-age=60")Kenapa ini penting: caching mengurangi beban server dan membuat UI terasa lebih cepat.
9) Security dasar yang wajib
Checklist minimal:
- helmet
- cors yang ketat
- rate limit
- sanitasi input
- hide stack trace di prod
Contoh setup:
import helmet from "helmet"
import cors from "cors"
app.use(helmet())
app.use(cors({ origin: ["https://jeryl.id"] }))Kenapa ini penting: security baseline menurunkan risiko exposed endpoint dan data leakage.
Contoh rate limit sederhana:
import rateLimit from "express-rate-limit"
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
})
app.use("/api/", limiter)Kenapa ini penting: rate limit mencegah abuse dan menjaga API tetap stabil.
10) Testing yang efektif
Minimal yang perlu:
- unit test service
- integration test endpoint kritis
- mock DB
Jika waktu terbatas, test flow utama terlebih dulu.
Contoh test minimal (supertest):
import request from "supertest"
import app from "../src/app"
it("GET /health", async () => {
await request(app).get("/health").expect(200)
})Kenapa ini penting: test sederhana mencegah regresi saat deploy cepat.
11) Graceful shutdown (sering dilupakan)
Supaya tidak ada request yang terputus saat deploy/restart:
const server = app.listen(3000)
process.on("SIGTERM", () => {
server.close(() => {
process.exit(0)
})
})Kenapa ini penting: tanpa shutdown yang rapi, request bisa terputus saat deploy.
12) Pagination + filtering yang konsisten
Standarkan page, limit, dan search agar API mudah dipakai:
app.get("/users", async (req, res) => {
const page = Number(req.query.page ?? 1)
const limit = Number(req.query.limit ?? 20)
const offset = (page - 1) * limit
const rows = await db.query("SELECT * FROM users LIMIT $1 OFFSET $2", [
limit,
offset,
])
res.json({ page, limit, data: rows })
})Kenapa ini penting: konsistensi pagination membuat frontend dan analytics lebih mudah dirawat.
13) Queue / background job (untuk task berat)
Gunakan queue untuk proses yang berat (email, export, report):
import { Queue } from "bullmq"
const exportQueue = new Queue("export")
app.post("/export", async (req, res) => {
await exportQueue.add("generate", { userId: req.user.id })
res.json({ status: "queued" })
})Kenapa ini penting: task berat tidak boleh memblok request utama.
Kesimpulan
Naik level di Express bukan soal trik, tapi disiplin: struktur yang jelas, middleware yang konsisten, error handling yang rapi, dan observability yang memadai. Jika fondasi ini kuat, aplikasi tetap stabil meski scale dan traffic naik.