Kita kini memiliki repository untuk data dan dependency injection untuk merakit. Lapisan tengah — service — menunggu untuk dibesarkan. Pola service layer menempatkan seluruh aturan bisnis pada satu kelompok object yang tidak mengerti HTTP, lalu menugaskan rute untuk menjadi duta yang setipis mungkin: menerjemahkan permintaan menjadi panggilan service, dan hasil service menjadi response. Pada chapter ini kita bangun server HTTP sungguhan dengan pola tersebut, lengkap dengan pemetaan error domain ke status code.

Masalah: Rute yang Menanggung Aturan Bisnis #

Di Section REST API kita menulis handler yang memvalidasi input langsung di dalam rute — wajar untuk aplikasi seukuran itu. Bayangkan aplikasi tersebut bertumbuh: rute POST /tugas memvalidasi judul, rute PUT /tugas/:id memvalidasi judul versi keduanya, worker impor dari berkas memvalidasi judul versi ketiganya. Suatu hari aturan berubah — judul juga wajib unik — dan tiga tempat harus diubah serempak. Tempat keempat yang terlupakan akan menyetujui data yang seharusnya ditolak.

Gejala klasiknya dapat diringkas seperti ini: aturan bisnis terduplikasi lintas rute, aturan tidak bisa diuji tanpa server HTTP, dan perubahan kecil menjalar ke banyak berkas. Pola service layer memindahkan seluruh aturan itu ke satu tempat yang bisa dipanggil siapa pun — rute HTTP, pekerja CLI, worker latar belakang, bahkan pengujian unit.

Rute Tipis, Service Tebal #

Pembagian kerjanya tegas. Handler mengurus segala hal yang berbau protokol; service mengurus segala hal yang berbau domain:

PekerjaanRute (handler)Service
Membaca body, path, queryYaTidak
Validasi format domain (judul, status)TidakYa
Menentukan status code (201, 400, 404)YaTidak
Melakukan query penyimpananTidakYa, lewat repository
Mengembalikan hasil atau melempar error domainTidakYa

Cara melaporkan kegagalan juga menjadi kesepakatan tersendiri. Chapter Pola Arsitektur Berlapis memakai object hasil { ok, galat }; pada chapter ini service melempar error berkelas khusus yang diwariskan dari Error — gaya yang lebih cocok ketika jalur kegagalan banyak dan pemanggil sudah terbiasa try/catch. Handler tinggal memeriksa kelas error untuk memilih status code.

Demo: Layanan Tugas di Balik Rute Tipis #

Server berikut memakai node:http dengan pola deterministik dari Section REST API — port sementara di 127.0.0.1, fetch berurutan, dan penutupan sambungan sebelum proses berakhir:

js
import http from 'node:http';

const host = '127.0.0.1';

function buatRepoTugas() {
  const tugas = new Map();
  let idBerikutnya = 1;
  return {
    simpan(tugasBaru) {
      const id = idBerikutnya++;
      const lengkap = { id, ...tugasBaru };
      tugas.set(id, lengkap);
      return lengkap;
    },
    semua() { return [...tugas.values()]; },
  };
}

class GalatValidasi extends Error {
  constructor(kode, pesan) {
    super(pesan);
    this.kode = kode;
  }
}

function buatLayananTugas({ repo }) {
  const STATUS = ['belum', 'proses', 'selesai'];
  return {
    buat(input) {
      const judul = typeof input.judul === 'string' ? input.judul.trim() : '';
      if (judul === '') throw new GalatValidasi('JUDUL_WAJIB', 'judul wajib diisi');
      if (judul.length > 80) throw new GalatValidasi('JUDUL_PANJANG', 'judul maksimal 80 karakter');
      const status = input.status ?? 'belum';
      if (!STATUS.includes(status)) {
        throw new GalatValidasi('STATUS_TAK_DIKENAL', `status harus salah satu dari: ${STATUS.join(', ')}`);
      }
      return repo.simpan({ judul, status });
    },
    daftar() { return repo.semua(); },
  };
}

function kirimJson(res, kode, data) {
  res.writeHead(kode, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

async function bacaBodyJson(req) {
  const potongan = [];
  for await (const data of req) potongan.push(data);
  return JSON.parse(Buffer.concat(potongan).toString('utf8'));
}

const layanan = buatLayananTugas({ repo: buatRepoTugas() });

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${host}`);

  if (url.pathname === '/tugas' && req.method === 'POST') {
    try {
      const body = await bacaBodyJson(req);
      const tugasBaru = layanan.buat(body);
      return kirimJson(res, 201, tugasBaru);
    } catch (e) {
      if (e instanceof GalatValidasi) {
        return kirimJson(res, 400, { error: { kode: e.kode, pesan: e.message } });
      }
      return kirimJson(res, 400, { error: { kode: 'BODY_SALAH', pesan: e.message } });
    }
  }
  if (url.pathname === '/tugas' && req.method === 'GET') {
    return kirimJson(res, 200, layanan.daftar());
  }
  return kirimJson(res, 404, { error: { kode: 'TIDAK_ADA', pesan: 'endpoint tidak ditemukan' } });
});

server.listen(0, host, async () => {
  const port = server.address().port;
  const base = `http://${host}:${port}`;

  const a = await fetch(`${base}/tugas`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ judul: 'Belajar service layer' }) });
  console.log('POST valid    ->', a.status, await a.text());

  const b = await fetch(`${base}/tugas`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ judul: '   ' }) });
  console.log('POST kosong   ->', b.status, await b.text());

  const c = await fetch(`${base}/tugas`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ judul: 'x'.repeat(81) }) });
  console.log('POST panjang  ->', c.status, await c.text());

  const d = await fetch(`${base}/tugas`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ judul: 'Sprint berikutnya', status: 'batal' }) });
  console.log('POST status   ->', d.status, await d.text());

  const e = await fetch(`${base}/tugas`);
  console.log('GET daftar    ->', e.status, await e.text());

  server.close();
  server.closeAllConnections();
});

Output yang Dihasilkan:

text
POST valid    -> 201 {"id":1,"judul":"Belajar service layer","status":"belum"}
POST kosong   -> 400 {"error":{"kode":"JUDUL_WAJIB","pesan":"judul wajib diisi"}}
POST panjang  -> 400 {"error":{"kode":"JUDUL_PANJANG","pesan":"judul maksimal 80 karakter"}}
POST status   -> 400 {"error":{"kode":"STATUS_TAK_DIKENAL","pesan":"status harus salah satu dari: belum, proses, selesai"}}
GET daftar    -> 200 [{"id":1,"judul":"Belajar service layer","status":"belum"}]

Mari kita bedah lapisan per lapisan:

  1. Baris 5-17 (buatRepoTugas): Repository tetap sederhana dan setia pada kontrak — menyimpan dan mengembalikan. Tidak ada satu aturan bisnis di sini; repo percaya bahwa data yang masuk sudah sah.
  2. Baris 19-24 (class GalatValidasi): Error domain diwariskan dari Error dengan tambahan properti kode. Properti inilah yang memungkinkan handler memilih status code tanpa membaca isi pesan.
  3. Baris 26-41 (buatLayananTugas): Seluruh aturan bisnis menumpuk di sini. Method buat memangkas judul, menolak judul kosong dan judul lebih dari 80 karakter, membatasi status pada tiga nilai sah dengan nilai bawaan belum, lalu menyerahkan penyimpanan ke repository. Daftar STATUS berada di dalam service — bukan konstanta global — karena aturan itu milik domain tugas.
  4. Baris 43-52 (dua helper HTTP): kirimJson dan bacaBodyJson adalah perkakas presentasi murni — pengetahuan tentang header, status, dan body stream tidak pernah masuk ke service.
  5. Baris 59-70 (rute POST): Inilah rute tipis yang kita tuju: baca body, panggil layanan.buat, kirim 201. Kegagalan ditangani satu tempat — instanceof GalatValidasi dipetakan ke 400 berikut kode dan pesannya, sedangkan kegagalan lain (misalnya body bukan JSON) jatuh ke BODY_SALAH. Tidak ada satu baris validasi pun di rute.
  6. Baris 81-94 (skenario): Lima permintaan menguji satu jalur sukses dan tiga jalur tolakan. Judul 81 karakter dibuat lewat 'x'.repeat(81) agar panjangnya presisi. Baris terakhir membuktikan hanya tugas yang sah yang tersimpan.

Menyatukan Pemetaan Error #

Pola try/catch dengan instanceof pada rute bekerja baik untuk dua rute; untuk banyak rute, pemetaan itu sendiri layak dipusatkan. Framework menawarkan rumah bawaannya: Express memiliki error-handling middleware — middleware empat argumen yang menangkap semua error yang lewat dari rute. Prinsipnya sama dengan demo kita: satu tempat menerjemahkan kelas error menjadi status code, sehingga rute tidak mengulang logika yang sama.

Dengan tiga lapisan utuh — presentasi, service, repository — aplikasi kita rapi secara vertikal. Namun ada pola penyusunan lain yang memotong arah ini secara horizontal: event, yaitu cara memberi tahu bagian kode lain bahwa sesuatu telah terjadi tanpa memanggilnya langsung. Chapter Event-Driven Architecture membahas pola tersebut.